diff --git a/AGENTS.md b/AGENTS.md index 3602672..fb91180 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,8 @@ operator-style tools, supporting two deployments from one codebase: - **Cloud (hosted HTTP):** a Starlette ASGI app that acts as an OAuth 2.1 Resource Server. It validates the client's bearer token and forwards it to the - Appwrite REST API. Served at `mcp.appwrite.io/mcp`. + Appwrite REST API. Served primarily at `mcp.appwrite.io/`, with `/mcp` also + available as a conventional alias. - **Self-hosted (`stdio`):** runs locally and authenticates with a project API key (`APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `APPWRITE_ENDPOINT`). @@ -23,7 +24,7 @@ Source lives in `src/mcp_server_appwrite/`: | File | Responsibility | | --- | --- | | `__main__.py` / `server.py` | Entry point, CLI args, transport selection (`--transport stdio\|http`), service registration, low-level MCP server. | -| `http_app.py` | Hosted Streamable-HTTP transport: `/mcp`, RFC 9728 protected-resource metadata, `/healthz`. | +| `http_app.py` | Hosted Streamable-HTTP transport: `/` plus the `/mcp` alias, RFC 9728 protected-resource metadata, `/healthz`. | | `auth.py` | OAuth 2.1 resource-server layer — bearer-token validation against the project's Appwrite authorization server. | | `service.py` | `Service` base class: introspects an Appwrite SDK service and turns its methods into MCP tool definitions. | | `tool_manager.py` | Registry of all services and their generated tools. | @@ -70,7 +71,7 @@ APPWRITE_PROJECT_ID= APPWRITE_API_KEY= \ uv run mcp-server-appwrite # Or via Docker (hosted HTTP/OAuth) -docker compose up --build # compose.yaml; endpoint at http://localhost:8000/mcp +docker compose up --build # compose.yaml; endpoint at http://localhost:8000/ ``` ## Pre-PR checklist diff --git a/README.md b/README.md index e68735c..d95f6e5 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server for Appwrite. It exposes Appwrite's API — databases, users, functions, teams, storage, and more — as tools your MCP client can call. -Connect to the hosted server at **`https://mcp.appwrite.io/mcp`** and authenticate +Connect to the hosted server at **`https://mcp.appwrite.io/`** and authenticate 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. +copy. The conventional `https://mcp.appwrite.io/mcp` URL is also supported and +connects to the same server. ![How the Appwrite MCP server handles OAuth and scopes](docs/appwrite-mcp-flow.png) @@ -21,7 +22,7 @@ Pick your client below. Each adds the hosted Appwrite Cloud server. Claude Code ```bash -claude mcp add --transport http appwrite https://mcp.appwrite.io/mcp +claude mcp add --transport http appwrite https://mcp.appwrite.io/ ``` Then, inside a Claude Code session, run `/mcp`, select **appwrite**, and follow @@ -33,7 +34,7 @@ the browser prompt to authenticate. Claude Desktop Go to **Settings → Connectors → Add custom connector** and paste -`https://mcp.appwrite.io/mcp`. Available on Pro and Max plans; on Team and +`https://mcp.appwrite.io/`. Available on Pro and Max plans; on Team and Enterprise plans only an organization Owner can add custom connectors. If you don't see that option (free plan, or a Team/Enterprise member), bridge @@ -45,7 +46,7 @@ the remote server through stdio instead (requires Node.js). Go to **Settings → "mcpServers": { "appwrite": { "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/mcp"] + "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/"] } } } @@ -67,7 +68,7 @@ Edit `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project). { "mcpServers": { "appwrite": { - "url": "https://mcp.appwrite.io/mcp" + "url": "https://mcp.appwrite.io/" } } } @@ -91,7 +92,7 @@ Palette → **MCP: Open User Configuration**. "servers": { "appwrite": { "type": "http", - "url": "https://mcp.appwrite.io/mcp" + "url": "https://mcp.appwrite.io/" } } } @@ -106,7 +107,7 @@ Edit `~/.codex/config.toml`. ```toml [mcp_servers.appwrite] -url = "https://mcp.appwrite.io/mcp" +url = "https://mcp.appwrite.io/" ``` Then authenticate from the terminal: @@ -116,7 +117,7 @@ codex mcp login appwrite ``` In the Codex GUI, you can instead add the server from the MCP settings — -set the URL to `https://mcp.appwrite.io/mcp` and leave the token and header +set the URL to `https://mcp.appwrite.io/` and leave the token and header fields empty (authentication happens through the browser): ![Appwrite MCP server settings in the Codex GUI](docs/images/codex-mcp.png) @@ -134,7 +135,7 @@ Edit `opencode.json` (project) or `~/.config/opencode/opencode.json` (global). "mcp": { "appwrite": { "type": "remote", - "url": "https://mcp.appwrite.io/mcp", + "url": "https://mcp.appwrite.io/", "enabled": true } } @@ -152,7 +153,7 @@ Edit `~/.codeium/windsurf/mcp_config.json`. { "mcpServers": { "appwrite": { - "serverUrl": "https://mcp.appwrite.io/mcp" + "serverUrl": "https://mcp.appwrite.io/" } } } @@ -164,7 +165,7 @@ Edit `~/.codeium/windsurf/mcp_config.json`. Gemini CLI ```bash -gemini mcp add --transport http appwrite https://mcp.appwrite.io/mcp +gemini mcp add --transport http appwrite https://mcp.appwrite.io/ ``` Or edit `~/.gemini/settings.json` (note the key is `httpUrl`, not `url`): @@ -173,7 +174,7 @@ Or edit `~/.gemini/settings.json` (note the key is `httpUrl`, not `url`): { "mcpServers": { "appwrite": { - "httpUrl": "https://mcp.appwrite.io/mcp" + "httpUrl": "https://mcp.appwrite.io/" } } } @@ -193,7 +194,7 @@ Edit `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json` (p { "mcpServers": { "appwrite": { - "serverUrl": "https://mcp.appwrite.io/mcp" + "serverUrl": "https://mcp.appwrite.io/" } } } @@ -209,7 +210,7 @@ Antigravity opens the browser OAuth flow automatically on first connect. If you GitHub Copilot CLI ```bash -copilot mcp add --transport http appwrite https://mcp.appwrite.io/mcp +copilot mcp add --transport http appwrite https://mcp.appwrite.io/ ``` Or run `/mcp add` inside a session, or edit `~/.copilot/mcp-config.json`: @@ -219,7 +220,7 @@ Or run `/mcp add` inside a session, or edit `~/.copilot/mcp-config.json`: "mcpServers": { "appwrite": { "type": "http", - "url": "https://mcp.appwrite.io/mcp" + "url": "https://mcp.appwrite.io/" } } } @@ -240,7 +241,7 @@ to your `settings.json` (`zed: open settings`): { "context_servers": { "appwrite": { - "url": "https://mcp.appwrite.io/mcp" + "url": "https://mcp.appwrite.io/" } } } @@ -254,7 +255,7 @@ Zed prompts you to authenticate through the browser on first connect. Warp Go to **Settings → Agents → MCP servers → + Add**, choose the URL-based -server type, and enter `https://mcp.appwrite.io/mcp`. +server type, and enter `https://mcp.appwrite.io/`. Warp opens a browser window to authenticate on first connect. @@ -272,7 +273,7 @@ Model Context Protocol (MCP) → Add**, switch to the JSON view, and paste: "mcpServers": { "appwrite": { "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/mcp"] + "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/"] } } } @@ -294,7 +295,7 @@ stdio (requires Node.js). In the Cline panel, open the **MCP Servers** icon → "mcpServers": { "appwrite": { "command": "npx", - "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/mcp"] + "args": ["-y", "mcp-remote", "https://mcp.appwrite.io/"] } } } diff --git a/docs/appwrite-mcp-flow.png b/docs/appwrite-mcp-flow.png index 83466ac..465e444 100644 Binary files a/docs/appwrite-mcp-flow.png and b/docs/appwrite-mcp-flow.png differ diff --git a/docs/appwrite-mcp-flow.svg b/docs/appwrite-mcp-flow.svg index c97b965..fcaaaa9 100644 --- a/docs/appwrite-mcp-flow.svg +++ b/docs/appwrite-mcp-flow.svg @@ -72,7 +72,7 @@ DISCOVERY - POST /mcp — no token + POST / — no token 1 @@ -96,11 +96,11 @@ Appwrite MCP server - mcp.appwrite.io/mcp + mcp.appwrite.io/ · also at /mcp Verify token - issuer · RS256 via JWKS · aud = /mcp + issuer · RS256 via JWKS · aud = / or /mcp 4 public tools diff --git a/docs/authentication.md b/docs/authentication.md index 38684dc..9cb9f24 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -14,20 +14,20 @@ MCP-aware clients run this automatically: ```mermaid sequenceDiagram participant C as MCP Client - participant S as MCP Server (/mcp) + participant S as MCP Server (/) participant A as Appwrite OAuth Server participant API as Appwrite REST API - C->>S: GET /mcp (no token) + C->>S: GET / (no token) S-->>C: 401 + WWW-Authenticate → metadata URL - C->>S: GET /.well-known/oauth-protected-resource/mcp + C->>S: GET /.well-known/oauth-protected-resource S-->>C: auth server + scopes (RFC 9728) C->>A: Discover (RFC 8414 / OIDC) C->>A: Self-register as public PKCE client (RFC 7591) A-->>C: client_id (no secret to pre-provision) C->>A: OAuth 2.1 + PKCE auth-code flow (RFC 8707 resource) A-->>C: access token (audience bound to this server) - C->>S: GET /mcp + Authorization: Bearer + C->>S: GET / + Authorization: Bearer S->>API: Forward bearer token API-->>S: Response S-->>C: Response @@ -37,3 +37,7 @@ The key detail is that clients **self-register** — the auth server exposes an `registration_endpoint` (RFC 7591), so there's no client ID or secret to pre-provision. Everything else is standard OAuth 2.1 + PKCE, with the RFC 8707 resource indicator binding each token's audience to this server. + +The conventional `/mcp` endpoint is a direct alias. It publishes matching +protected-resource metadata at `/.well-known/oauth-protected-resource/mcp`, and +tokens audience-bound to either URL are accepted by the shared service. diff --git a/docs/development.md b/docs/development.md index c459047..66f34bc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,7 +24,8 @@ curl -LsSf https://astral.sh/uv/install.sh | sh ## Run the server **Docker Compose** — hosted HTTP/OAuth transport, endpoint at -`http://localhost:8000/mcp` (default `MCP_PUBLIC_URL=http://localhost:8000`): +`http://localhost:8000/` (default `MCP_PUBLIC_URL=http://localhost:8000`; +`/mcp` is also supported): ```bash docker compose up --build @@ -71,7 +72,7 @@ Run the MCP Inspector against a server: npx @modelcontextprotocol/inspector ``` -To debug the hosted transport, point it at `https://mcp.appwrite.io/mcp` and +To debug the hosted transport, point it at `https://mcp.appwrite.io/` and complete the OAuth flow when prompted. For self-hosted, start the Inspector in stdio mode with `uv run mcp-server-appwrite` as the command and the `APPWRITE_*` env vars above. diff --git a/server.template.json b/server.template.json index 8b41735..8ec17a8 100644 --- a/server.template.json +++ b/server.template.json @@ -10,7 +10,7 @@ "remotes": [ { "type": "streamable-http", - "url": "https://mcp.appwrite.io/mcp" + "url": "https://mcp.appwrite.io/" } ], "packages": [ diff --git a/src/mcp_server_appwrite/auth.py b/src/mcp_server_appwrite/auth.py index 71f1baa..451209c 100644 --- a/src/mcp_server_appwrite/auth.py +++ b/src/mcp_server_appwrite/auth.py @@ -6,10 +6,11 @@ forwarded to the Appwrite REST API, which natively accepts it. This deployment is single-tenant: it serves one Appwrite project — the Cloud -console project by default, overridable via ``APPWRITE_PROJECT_ID`` — so the MCP -endpoint is simply ``/mcp`` with no project in the path. Tokens must be issued by -that project's authorization server (``/oauth2/``); a token -whose issuer names any other project is rejected. +console project by default, overridable via ``APPWRITE_PROJECT_ID`` — at the root +endpoint, with ``/mcp`` also available as a conventional alias. Tokens must be +issued by that project's authorization server +(``/oauth2/``); a token whose issuer names any other project +is rejected. """ from __future__ import annotations @@ -61,13 +62,30 @@ def issuer_url() -> str: def canonical_resource() -> str: """RFC 8707 canonical resource URI for this MCP server.""" + return f"{public_base_url()}/" + + +def mcp_path_resource() -> str: + """Resource URI for clients that use the conventional ``/mcp`` path.""" return f"{public_base_url()}/mcp" -def resource_metadata_url() -> str: +def accepted_resources() -> tuple[str, str]: + """Resource audiences accepted by the two shared MCP endpoints.""" + return canonical_resource(), mcp_path_resource() + + +def resource_metadata_url(resource: str | None = None) -> str: """RFC 9728 protected-resource metadata URL (well-known path + resource path).""" + resource = resource or canonical_resource() + if resource == canonical_resource(): + path = "/.well-known/oauth-protected-resource" + elif resource == mcp_path_resource(): + path = "/.well-known/oauth-protected-resource/mcp" + else: + raise ValueError(f"Unsupported MCP resource: {resource}") + parts = urlsplit(public_base_url()) - path = "/.well-known/oauth-protected-resource/mcp" return urlunsplit((parts.scheme, parts.netloc, path, "", "")) @@ -297,22 +315,26 @@ async def _filter_deprecated_scopes(scopes: list[str]) -> list[str]: return filtered -def build_resource_metadata(scopes: list[str], authorization_servers=None) -> dict: +def build_resource_metadata( + scopes: list[str], authorization_servers=None, *, resource: str | None = None +) -> dict: """RFC 9728 Protected Resource Metadata document.""" return { - "resource": canonical_resource(), + "resource": resource or canonical_resource(), "authorization_servers": authorization_servers or [issuer_url()], "scopes_supported": scopes, "bearer_methods_supported": ["header"], } -async def protected_resource_metadata() -> dict: +async def protected_resource_metadata(*, resource: str | None = None) -> dict: """RFC 9728 Protected Resource Metadata, with scopes validated against AS discovery.""" metadata = await authorization_server_metadata() scopes = await _filter_deprecated_scopes(_advertised_scopes(metadata)) - return build_resource_metadata(scopes, [metadata["issuer"]]) + return build_resource_metadata( + scopes, [metadata["issuer"]], resource=resource or canonical_resource() + ) def project_id_from_issuer(iss: str | None) -> str | None: @@ -402,8 +424,8 @@ def _verify_sync(self, token: str) -> AccessToken | None: _log(f"Rejecting token: verification failed ({exc}).") return None - expected_resource = canonical_resource() - if not self._audience_ok(claims.get("aud"), expected_resource): + matched_resource = self._accepted_resource(claims.get("aud")) + if matched_resource is None: return None scope_claim = claims.get("scope") or claims.get("scp") or "" @@ -418,24 +440,26 @@ def _verify_sync(self, token: str) -> AccessToken | None: ), scopes=scopes, expires_at=int(claims["exp"]) if "exp" in claims else None, - resource=expected_resource, + resource=matched_resource, subject=claims.get("sub"), claims={**claims, "project_id": project_id}, ) - def _audience_ok(self, aud, expected_resource: str) -> bool: + def _accepted_resource(self, aud) -> str | None: # Tokens must be audience-bound to this MCP server (RFC 8707). The Appwrite # OAuth server always issues Resource Indicators, so a missing or mismatched # audience is a hard rejection. audiences = ( [aud] if isinstance(aud, str) else list(aud) if aud is not None else [] ) - if expected_resource in audiences: - return True + for resource in accepted_resources(): + if resource in audiences: + return resource _log( - f"Rejecting token: audience {audiences!r} not bound to {expected_resource!r}." + f"Rejecting token: audience {audiences!r} not bound to any accepted " + f"resource {list(accepted_resources())!r}." ) - return False + return None async def verify_token(self, token: str) -> AccessToken | None: access_token = await to_thread.run_sync(self._verify_sync, token) diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index a5d5f01..d76d58f 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -2,12 +2,11 @@ Builds a single-tenant Starlette ASGI app for the served Appwrite project: -* ``/mcp`` — the MCP Streamable-HTTP endpoint, gated by a bearer-token check that - returns an RFC 9728 ``WWW-Authenticate`` challenge when unauthenticated. -* ``/.well-known/oauth-protected-resource/mcp`` — RFC 9728 protected resource - metadata pointing at the project's Appwrite OAuth authorization server. Also - served at the root form (no ``/mcp`` suffix) for clients that don't apply - path insertion. +* ``/`` — the primary MCP Streamable-HTTP endpoint, gated by a bearer-token check + that returns an RFC 9728 ``WWW-Authenticate`` challenge when unauthenticated. +* ``/mcp`` — a conventional direct alias for the same MCP endpoint. +* ``/.well-known/oauth-protected-resource`` and its ``/mcp`` path-insertion + variant — RFC 9728 protected resource metadata for their matching endpoints. * ``/.well-known/oauth-authorization-server`` — backwards-compatibility shim for clients on the 2025-03-26 MCP authorization spec (e.g. Raycast), which expect authorization-server metadata at the MCP server's own origin instead of @@ -50,6 +49,8 @@ from .auth import ( AppwriteTokenVerifier, authorization_server_metadata, + canonical_resource, + mcp_path_resource, protected_resource_metadata, public_base_url, resource_metadata_url, @@ -100,9 +101,10 @@ def _icon_svg() -> bytes: return files("mcp_server_appwrite.assets").joinpath("favicon.svg").read_bytes() -async def _send_401(send: Send) -> None: +async def _send_401(send: Send, resource: str | None = None) -> None: """RFC 9728 §5.1 — 401 with a WWW-Authenticate pointing to resource metadata.""" - metadata_url = resource_metadata_url() + resource = resource or canonical_resource() + metadata_url = resource_metadata_url(resource) parts = [ 'error="invalid_token"', 'error_description="Authentication required"', @@ -116,7 +118,9 @@ async def _send_401(send: Send) -> None: # 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 = (await protected_resource_metadata(resource=resource)).get( + "scopes_supported" + ) or [] scopes = [ scope for scope in scopes @@ -142,7 +146,7 @@ async def _send_401(send: Send) -> None: class RequireBearer: - """ASGI gate: require an authenticated user for ``/mcp`` requests. + """ASGI gate: require an authenticated user for MCP endpoint requests. Scope enforcement is delegated to the Appwrite REST API (per-route scope checks against the token's granted scopes), so the gate only requires a valid token. @@ -168,7 +172,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: # handshake failures. if _has_authorization_header(scope): telemetry.record_handshake_failure(reason="invalid_token") - await _send_401(send) + resource = ( + mcp_path_resource() + if scope.get("path") == "/mcp" + else canonical_resource() + ) + await _send_401(send, resource) return await self.app(scope, receive, send) @@ -229,7 +238,7 @@ def _find_initialize_params(body: bytes) -> dict | None: class MCPIdentityMiddleware: - """Bind client/user identity for every authenticated ``/mcp`` request. + """Bind client/user identity for every authenticated MCP request. The hosted transport is stateless: each POST is its own MCP session, so ``session.client_params`` is only populated on the request that carries the @@ -294,7 +303,15 @@ def _bind_identity(self, scope: Scope, body: bytes) -> None: async def protected_resource_metadata_endpoint(request: Request) -> JSONResponse: - metadata = await protected_resource_metadata() + metadata = await protected_resource_metadata(resource=canonical_resource()) + headers = {**CORS_HEADERS, "Link": _icon_link_header()} + return JSONResponse(metadata, headers=headers) + + +async def mcp_path_protected_resource_metadata_endpoint( + request: Request, +) -> JSONResponse: + metadata = await protected_resource_metadata(resource=mcp_path_resource()) headers = {**CORS_HEADERS, "Link": _icon_link_header()} return JSONResponse(metadata, headers=headers) @@ -357,10 +374,9 @@ async def lifespan(app: Starlette): routes = [ Route( "/.well-known/oauth-protected-resource/mcp", - endpoint=protected_resource_metadata_endpoint, + endpoint=mcp_path_protected_resource_metadata_endpoint, methods=["GET", "OPTIONS"], ), - # Root form, for clients that don't apply RFC 9728 path insertion. Route( "/.well-known/oauth-protected-resource", endpoint=protected_resource_metadata_endpoint, @@ -376,6 +392,11 @@ async def lifespan(app: Starlette): Route("/favicon.svg", endpoint=favicon_svg_endpoint, methods=["GET"]), Route("/favicon.ico", endpoint=favicon_ico_endpoint, methods=["GET"]), Route("/healthz", endpoint=health_endpoint, methods=["GET"]), + Route( + "/", + endpoint=mcp_endpoint, + methods=["GET", "POST", "DELETE", "OPTIONS"], + ), Route( "/mcp", endpoint=mcp_endpoint, @@ -404,5 +425,5 @@ def run_http(*, host: str = "0.0.0.0", port: int = 8000) -> None: } log_config["handlers"]["access"]["filters"] = ["hide_healthz"] - _log(f"Serving on http://{host}:{port} (resource path: /mcp)") + _log(f"Serving on http://{host}:{port}/ (also available at /mcp)") uvicorn.run(app, host=host, port=port, log_config=log_config) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 4020225..cf2e88a 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -40,15 +40,20 @@ def test_issuer_and_resource_urls(self): self.assertEqual( auth.issuer_url(), "https://cloud.appwrite.io/v1/oauth2/console" ) - self.assertEqual(auth.canonical_resource(), "https://mcp.appwrite.io/mcp") + self.assertEqual(auth.canonical_resource(), "https://mcp.appwrite.io/") + self.assertEqual(auth.mcp_path_resource(), "https://mcp.appwrite.io/mcp") self.assertEqual( auth.resource_metadata_url(), + "https://mcp.appwrite.io/.well-known/oauth-protected-resource", + ) + self.assertEqual( + auth.resource_metadata_url(auth.mcp_path_resource()), "https://mcp.appwrite.io/.well-known/oauth-protected-resource/mcp", ) def test_build_resource_metadata_shape(self): meta = auth.build_resource_metadata(["users.read", "teams.read"]) - self.assertEqual(meta["resource"], "https://mcp.appwrite.io/mcp") + self.assertEqual(meta["resource"], "https://mcp.appwrite.io/") self.assertEqual( meta["authorization_servers"], ["https://cloud.appwrite.io/v1/oauth2/console"], @@ -417,21 +422,29 @@ def setUp(self): patcher.start() self.addCleanup(patcher.stop) self.verifier = auth.AppwriteTokenVerifier() - self.resource = "https://mcp.appwrite.io/mcp" + self.resource = "https://mcp.appwrite.io/" + self.mcp_path_resource = "https://mcp.appwrite.io/mcp" def test_audience_match_string(self): - self.assertTrue(self.verifier._audience_ok(self.resource, self.resource)) + self.assertEqual(self.verifier._accepted_resource(self.resource), self.resource) + + def test_mcp_path_audience_match_string(self): + self.assertEqual( + self.verifier._accepted_resource(self.mcp_path_resource), + self.mcp_path_resource, + ) def test_audience_match_list(self): - self.assertTrue( - self.verifier._audience_ok(["other", self.resource], self.resource) + self.assertEqual( + self.verifier._accepted_resource(["other", self.resource]), + self.resource, ) def test_audience_mismatch_rejected(self): - self.assertFalse(self.verifier._audience_ok("nope", self.resource)) + self.assertIsNone(self.verifier._accepted_resource("nope")) def test_missing_audience_rejected(self): - self.assertFalse(self.verifier._audience_ok(None, self.resource)) + self.assertIsNone(self.verifier._accepted_resource(None)) def test_verify_rejects_token_for_other_project(self): # A token issued by a different project's authorization server must be diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 8497a95..10f6189 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -12,11 +12,13 @@ from mcp_server_appwrite.http_app import ( HealthzAccessLogFilter, MCPIdentityMiddleware, + RequireBearer, _client_from_user_agent, _find_initialize_params, _send_401, authorization_server_metadata_endpoint, build_app, + mcp_path_protected_resource_metadata_endpoint, protected_resource_metadata_endpoint, ) @@ -67,14 +69,23 @@ def setUp(self): self.addCleanup(patcher.stop) auth._deprecated_scope_cache.clear() self.addCleanup(auth._deprecated_scope_cache.clear) + auth._store_discovery( + "console", + { + "issuer": "https://cloud.appwrite.io/v1/oauth2/console", + "jwks_uri": "https://cloud.appwrite.io/v1/oauth2/console/jwks", + "scopes_supported": [], + }, + ) + self.addCleanup(lambda: auth._discovery_cache.pop("console", None)) - def _challenge(self) -> str: + def _challenge(self, resource: str | None = None) -> str: messages = [] async def send(message): messages.append(message) - asyncio.run(_send_401(send)) + asyncio.run(_send_401(send, resource)) start = messages[0] self.assertEqual(start["status"], 401) headers = dict(start["headers"]) @@ -106,6 +117,22 @@ def test_401_includes_scope_hint_from_discovery(self): self.assertIn('resource_metadata="', challenge) self.assertIn('scope="openid all project:users.read"', challenge) + def test_401_uses_metadata_for_requested_resource(self): + with self._empty_deprecated_scope_catalog(): + root_challenge = self._challenge(auth.canonical_resource()) + mcp_path_challenge = self._challenge(auth.mcp_path_resource()) + + self.assertIn( + 'resource_metadata="https://mcp.appwrite.io/' + '.well-known/oauth-protected-resource"', + root_challenge, + ) + self.assertIn( + 'resource_metadata="https://mcp.appwrite.io/' + '.well-known/oauth-protected-resource/mcp"', + mcp_path_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 @@ -173,6 +200,52 @@ def test_401_omits_scope_hint_when_discovery_unavailable(self): self.assertNotIn("scope=", challenge) +class RequireBearerTests(unittest.TestCase): + def _challenge_for_path(self, path: str) -> str: + messages = [] + + async def app(scope, receive, send): # pragma: no cover - must stay gated + self.fail("unauthenticated request reached the MCP handler") + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + messages.append(message) + + async def metadata(*, resource=None): + return {"resource": resource, "scopes_supported": []} + + scope = {"type": "http", "method": "GET", "path": path, "headers": []} + with ( + mock.patch.dict( + os.environ, {"MCP_PUBLIC_URL": "http://localhost:8000"}, clear=False + ), + mock.patch( + "mcp_server_appwrite.http_app.protected_resource_metadata", metadata + ), + ): + asyncio.run(RequireBearer(app)(scope, receive, send)) + + self.assertEqual(messages[0]["status"], 401) + return dict(messages[0]["headers"])[b"www-authenticate"].decode() + + def test_challenge_matches_requested_endpoint(self): + root_challenge = self._challenge_for_path("/") + mcp_path_challenge = self._challenge_for_path("/mcp") + + self.assertIn( + 'resource_metadata="http://localhost:8000/' + '.well-known/oauth-protected-resource"', + root_challenge, + ) + self.assertIn( + 'resource_metadata="http://localhost:8000/' + '.well-known/oauth-protected-resource/mcp"', + mcp_path_challenge, + ) + + class WellKnownMetadataEndpointTests(unittest.TestCase): """Discovery endpoints, including the legacy shims for clients on the 2025-03-26 MCP authorization spec (e.g. Raycast) that fetch @@ -213,6 +286,15 @@ def test_protected_resource_metadata_names_canonical_resource(self): response = asyncio.run(protected_resource_metadata_endpoint(mock.Mock())) self.assertEqual(response.status_code, 200) body = self._body(response) + self.assertEqual(body["resource"], "https://mcp.appwrite.io/") + self.assertEqual(body["authorization_servers"], [self.DISCOVERY["issuer"]]) + + def test_mcp_path_metadata_names_matching_resource(self): + response = asyncio.run( + mcp_path_protected_resource_metadata_endpoint(mock.Mock()) + ) + self.assertEqual(response.status_code, 200) + body = self._body(response) self.assertEqual(body["resource"], "https://mcp.appwrite.io/mcp") self.assertEqual(body["authorization_servers"], [self.DISCOVERY["issuer"]]) @@ -224,6 +306,8 @@ def test_app_routes_include_legacy_discovery_paths(self): original_transport = server_module._UPLOAD_TRANSPORT self.addCleanup(setattr, server_module, "_UPLOAD_TRANSPORT", original_transport) paths = {getattr(route, "path", None) for route in build_app().routes} + self.assertIn("/", paths) + self.assertIn("/mcp", paths) self.assertIn("/.well-known/oauth-protected-resource/mcp", paths) self.assertIn("/.well-known/oauth-protected-resource", paths) self.assertIn("/.well-known/oauth-authorization-server", paths)