diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index 1eba6ec2..6b0376ed 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -17,7 +17,7 @@ Accepts MCP Streamable HTTP JSON requests (legacy handshake and modern `2026-07- | Content-Type | Must be `application/json` | Header | String | application/json | | Accept | Prefer `application/json, text/event-stream` | Header | String | application/json, text/event-stream | | MCP-Protocol-Version | Optional. Use `2026-07-28` for the modern path | Header | String | 2025-06-18 | -| Authorization | Required when `MCP_AUTH_MODE=bearer` | Header | String | Bearer s3cr3t | +| Authorization | Required when `MCP_AUTH_MODE` is `bearer` or `oauth` | Header | String | Bearer s3cr3t | **Response** @@ -63,6 +63,14 @@ Sample `401` โ€” bearer auth failure: } ``` +### GET /.well-known/oauth-protected-resource + +`MCP_AUTH_MODE=oauth` only. [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) metadata pointing MCP clients at the project's Appwrite OAuth2 server. Unauthenticated `POST /` returns `401` with `WWW-Authenticate: Bearer resource_metadata="โ€ฆ"` so clients find it. + +### GET /oauth/consent + +`MCP_AUTH_MODE=oauth` only. Sign-in and consent screen for the project's OAuth2 server. + ### OPTIONS / CORS preflight. Returns `204` with `Access-Control-Allow-*` headers. @@ -77,6 +85,7 @@ Not supported (no SSE streams, no sessions). Returns `405`. | ---- | ---------------- | ---------------- | | echo | `text: string` | echoed string | | add | `a: float`, `b: float` | sum | +| list_tasks | `limit: int = 25` (max 100), `cursor: string` | a page of the signed-in user's tasks and `next_cursor` (`oauth` mode, needs the `tasks.read` scope) | ### Connect a client @@ -113,6 +122,18 @@ With bearer auth, set Function env `MCP_AUTH_MODE=bearer` + `MCP_AUTH_TOKEN=...` } ``` +### Per-user sign-in (OAuth) + +With `MCP_AUTH_MODE=oauth`, MCP clients sign users in through the project's Appwrite OAuth2 server (Appwrite Cloud). Your tools are the API: each checks one of your own scopes (`oauth.require_scope`) and reads Appwrite as the signed-in user (`oauth.user_client`), so row permissions apply. The access token only carries your scopes and never touches Appwrite's API directly. + +1. Enable the project's OAuth2 server with **Authorization URL** `https://.appwrite.run/oauth/consent` and your scopes, e.g. `openid tasks.read`. +2. Add a **Web platform** for `.appwrite.run` so the consent page can call Appwrite. +3. Give the function the `users.write` execution scope, so it can act as the signed-in user. +4. Set `MCP_AUTH_MODE=oauth` and `MCP_AUTH_SCOPES=openid tasks.read`. +5. Give users access with row permissions, e.g. `read("user:")`. + +Clients register themselves (dynamic client registration), so the connect step is just the URL. + ### Smoke test ```bash @@ -153,6 +174,7 @@ Appwrite Functions are short-lived request/response workers. They do not run a S | Build Commands | `pip install -r requirements.txt` | | Permissions | `any` | | Timeout (Seconds) | 30 | +| Scopes | `users.write` (OAuth mode only) | ## ๐Ÿ”’ Environment Variables @@ -167,7 +189,7 @@ Display name returned in `initialize` โ†’ `serverInfo.name`. ### MCP_AUTH_MODE -Auth gate for the endpoint. `none` (default) is open; `bearer` requires `Authorization: Bearer `. +Auth gate for the endpoint. `none` (default) is open; `bearer` requires `Authorization: Bearer `; `oauth` requires an access token from the project's Appwrite OAuth2 server. | Question | Answer | | ------------ | -------------- | @@ -183,6 +205,33 @@ Shared secret when `MCP_AUTH_MODE=bearer`. Compared with `hmac.compare_digest`. | Required | Yes (when bearer) | | Sample Value | `s3cr3t...token` | +### MCP_AUTH_SCOPES + +Space-separated scopes advertised to MCP clients when `MCP_AUTH_MODE=oauth`. + +| Question | Answer | +| ------------ | --------------------------- | +| Required | No | +| Sample Value | `openid tasks.read` | + +### MCP_RESOURCE + +Resource identifier tokens must be issued for. Defaults to `https://`; set it for custom domains behind a proxy or local development. + +| Question | Answer | +| ------------ | ------------------------------------- | +| Required | No | +| Sample Value | `https://mcp.example.com` | + +### MCP_AUTH_ISSUER + +OAuth2 issuer. Defaults to `/oauth2/`. + +| Question | Answer | +| ------------ | --------------------------------------------------- | +| Required | No | +| Sample Value | `https://fra.cloud.appwrite.io/v1/oauth2/` | + ### MCP_TOOL_TIMEOUT Soft deadline (seconds) for the whole request, before Appwrite's 30s domain hard-cap. diff --git a/python/mcp-server/requirements.txt b/python/mcp-server/requirements.txt index 6e6ac958..0219af10 100644 --- a/python/mcp-server/requirements.txt +++ b/python/mcp-server/requirements.txt @@ -1 +1,3 @@ mcp>=2.0.0,<3.0.0 +PyJWT[crypto]>=2.10,<3 +appwrite>=24,<25 diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index 7d1d42da..2510d683 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -4,7 +4,9 @@ Edit this file when building your own server. Do not name it `server.py` โ€” that conflicts with the Open Runtimes runtime module. -To call Appwrite APIs from a tool, inject ``ctx: Context`` and read the +With ``MCP_AUTH_MODE=oauth``, tools are your app's API: gate each one on an +app-defined scope with ``oauth.require_scope`` and query Appwrite as the signed-in +user with ``oauth.user_client`` (see ``list_tasks`` below). Otherwise read the ephemeral API key from inbound headers: from mcp.server.mcpserver import Context @@ -18,8 +20,21 @@ def list_users(ctx: Context) -> dict: from __future__ import annotations import os +from typing import Any, TypedDict -from mcp.server.mcpserver import MCPServer +from appwrite.query import Query +from appwrite.services.tables_db import TablesDB +from mcp.server.mcpserver import Context, MCPServer + +from appwrite_mcp import oauth + +TASKS_DATABASE_ID = "main" +TASKS_TABLE_ID = "tasks" + + +class TaskPage(TypedDict): + tasks: list[dict[str, Any]] + next_cursor: str | None server = MCPServer( name=os.environ.get("MCP_SERVER_NAME") or "appwrite-hosted-mcp", @@ -39,3 +54,15 @@ def echo(text: str) -> str: @server.tool(description="Add two numbers.") def add(a: float, b: float) -> float: return a + b + + +@server.tool(description="List the signed-in user's tasks, a page at a time. Pass next_cursor back as cursor for more.") +def list_tasks(ctx: Context, limit: int = 25, cursor: str | None = None) -> TaskPage: + oauth.require_scope("tasks.read") + page_size = max(1, min(limit, 100)) + queries = [Query.limit(page_size)] + if cursor: + queries.append(Query.cursor_after(cursor)) + rows = TablesDB(oauth.user_client(ctx.headers)).list_rows(TASKS_DATABASE_ID, TASKS_TABLE_ID, queries).to_dict()["rows"] + tasks = [{**row["data"], "id": row["$id"]} for row in rows] + return {"tasks": tasks, "next_cursor": tasks[-1]["id"] if len(tasks) == page_size else None} diff --git a/python/mcp-server/src/appwrite_mcp/auth.py b/python/mcp-server/src/appwrite_mcp/auth.py index 723b099f..ad191bb1 100644 --- a/python/mcp-server/src/appwrite_mcp/auth.py +++ b/python/mcp-server/src/appwrite_mcp/auth.py @@ -6,6 +6,10 @@ import os from typing import Any +import jwt + +from . import oauth + def auth_mode() -> str: return (os.environ.get("MCP_AUTH_MODE") or "none").strip().lower() @@ -15,7 +19,7 @@ def expected_token() -> str: return os.environ.get("MCP_AUTH_TOKEN") or "" -def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: +def check_auth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None, dict[str, Any] | None]: """ Returns (ok, error_payload). error_payload is a dict suitable for context.res.json(..., 401, headers) @@ -23,7 +27,10 @@ def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: """ mode = auth_mode() if mode in ("", "none", "open", "false", "0"): - return True, None + return True, None, None + + if mode == "oauth": + return _check_oauth(headers, resource) if mode != "bearer": return False, { @@ -37,7 +44,7 @@ def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: }, "status": 500, "headers": {}, - } + }, None token = expected_token() if not token: @@ -52,21 +59,32 @@ def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: }, "status": 500, "headers": {}, - } + }, None + + scheme, _, provided = (headers.get("authorization") or "").strip().partition(" ") + if scheme.lower() != "bearer" or not provided.strip(): + return False, _unauthorized("Missing or invalid Authorization header"), None + + if not hmac.compare_digest(provided.strip(), token): + return False, _unauthorized("Invalid bearer token"), None + + return True, None, None + - auth = (headers.get("authorization") or "").strip() - prefix = "Bearer " - if not auth.startswith(prefix): - return False, _unauthorized("Missing or invalid Authorization header") +def _check_oauth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None, dict[str, Any] | None]: + scheme, _, token = (headers.get("authorization") or "").strip().partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return False, _unauthorized("Missing or invalid Authorization header", oauth.challenge(resource)), None - provided = auth[len(prefix) :].strip() - if not hmac.compare_digest(provided, token): - return False, _unauthorized("Invalid bearer token") + try: + verified = oauth.verify(token.strip(), resource) + except jwt.PyJWTError as error: + return False, _unauthorized(f"Invalid access token: {error}", oauth.challenge(resource, "invalid_token")), None - return True, None + return True, None, verified -def _unauthorized(message: str) -> dict[str, Any]: +def _unauthorized(message: str, challenge: str = "Bearer") -> dict[str, Any]: return { "body": { "jsonrpc": "2.0", @@ -74,5 +92,5 @@ def _unauthorized(message: str) -> dict[str, Any]: "error": {"code": -32001, "message": message}, }, "status": 401, - "headers": {"WWW-Authenticate": "Bearer"}, + "headers": {"WWW-Authenticate": challenge}, } diff --git a/python/mcp-server/src/appwrite_mcp/consent.html b/python/mcp-server/src/appwrite_mcp/consent.html new file mode 100644 index 00000000..ba503514 --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/consent.html @@ -0,0 +1,137 @@ + + + + + +Authorize access + + + +
+ + + + + +
+ + + + + diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py new file mode 100644 index 00000000..6beb5728 --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -0,0 +1,123 @@ +# OAuth 2.1 resource-server side of MCP auth, backed by the project's Appwrite OAuth2 server. + +from __future__ import annotations + +import os +from contextvars import ContextVar +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import jwt +from appwrite.client import Client +from appwrite.services.users import Users +from mcp.server.mcpserver.exceptions import ToolError + +METADATA_PATH = "/.well-known/oauth-protected-resource" +CONSENT_PATH = "/oauth/consent" +JWKS_TIMEOUT = 5 +USER_JWT_DURATION = 60 + +_jwks: jwt.PyJWKClient | None = None + +verified_claims: ContextVar[dict[str, Any] | None] = ContextVar("verified_claims", default=None) + + +def issuer() -> str: + explicit = os.environ.get("MCP_AUTH_ISSUER") + if explicit: + return explicit.rstrip("/") + endpoint = os.environ["APPWRITE_FUNCTION_API_ENDPOINT"].rstrip("/") + return f"{endpoint}/oauth2/{os.environ['APPWRITE_FUNCTION_PROJECT_ID']}" + + +def consent_page() -> str: + """Sign-in and consent screen for the project's OAuth2 server; set it as the server's authorization URL.""" + html = (Path(__file__).parent / "consent.html").read_text() + return html.replace("__APPWRITE_ENDPOINT__", os.environ["APPWRITE_FUNCTION_API_ENDPOINT"]).replace( + "__APPWRITE_PROJECT__", os.environ["APPWRITE_FUNCTION_PROJECT_ID"] + ) + + +def scopes() -> list[str]: + return (os.environ.get("MCP_AUTH_SCOPES") or "").split() + + +def resource(host: str) -> str: + """Function domains are HTTPS-only, but the executor forwards requests as plain HTTP.""" + return (os.environ.get("MCP_RESOURCE") or f"https://{host}").rstrip("/") + + +def metadata(resource_url: str) -> dict[str, Any]: + """RFC 9728 protected resource metadata, pointing MCP clients at the Appwrite OAuth2 server.""" + document: dict[str, Any] = { + "resource": resource_url, + "authorization_servers": [issuer()], + "bearer_methods_supported": ["header"], + } + if scopes(): + document["scopes_supported"] = scopes() + return document + + +def metadata_url(resource_url: str) -> str: + """RFC 9728 ยง3.1: the well-known segment goes between the host and the resource's path.""" + resource = urlsplit(resource_url) + return f"{resource.scheme}://{resource.netloc}{METADATA_PATH}{resource.path.rstrip('/')}" + + +def challenge(resource_url: str, error: str | None = None) -> str: + parts = [f'resource_metadata="{metadata_url(resource_url)}"'] + if scopes(): + parts.append(f'scope="{" ".join(scopes())}"') + if error: + parts.append(f'error="{error}"') + return "Bearer " + ", ".join(parts) + + +def verify(token: str, resource_url: str) -> dict[str, Any]: + """Validate an Appwrite-issued access token (RFC 9068) for this resource. Raises jwt.PyJWTError.""" + global _jwks + if _jwks is None: + _jwks = jwt.PyJWKClient(f"{issuer()}/.well-known/jwks.json", timeout=JWKS_TIMEOUT) + + token_type = jwt.get_unverified_header(token).get("typ") + if not isinstance(token_type, str) or token_type.lower() != "at+jwt": + raise jwt.InvalidTokenError("Not an OAuth2 access token") + + return jwt.decode( + token, + _jwks.get_signing_key_from_jwt(token).key, + algorithms=["RS256"], + issuer=issuer(), + audience=resource_url, + options={"require": ["exp", "iat", "sub", "aud", "iss"]}, + ) + + +def claims() -> dict[str, Any]: + """Claims of the access token the transport verified for this request; refuses without one.""" + verified = verified_claims.get() + if verified is None: + raise ToolError("Sign-in required: this tool needs MCP_AUTH_MODE=oauth") + return verified + + +def require_scope(scope: str) -> None: + """Fail the tool call unless the user granted this app-defined scope (e.g. ``tasks.read``).""" + if scope not in claims().get("scope", "").split(): + raise ToolError(f"Missing scope: {scope}") + + +def user_client(headers: Any) -> Client: + """Appwrite client acting as the signed-in user, so row permissions apply. + + The function's own key mints a short-lived JWT for the token's user; the access token + itself carries only app-defined scopes and never reaches Appwrite. Needs the function's + ``users.write`` execution scope. + """ + endpoint = os.environ["APPWRITE_FUNCTION_API_ENDPOINT"] + project = os.environ["APPWRITE_FUNCTION_PROJECT_ID"] + server = Client().set_endpoint(endpoint).set_project(project).set_key((headers or {}).get("x-appwrite-key", "")) + user_jwt = Users(server).create_jwt(claims()["sub"], duration=USER_JWT_DURATION) + return Client().set_endpoint(endpoint).set_project(project).set_jwt(user_jwt.jwt) diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py index 00800c56..bf522816 100644 --- a/python/mcp-server/src/appwrite_mcp/transport.py +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -9,7 +9,8 @@ from mcp.server.mcpserver import MCPServer -from .auth import check_auth +from . import oauth +from .auth import auth_mode, check_auth from .dispatch import dispatch CORS_HEADERS = { @@ -19,7 +20,7 @@ "Content-Type, Accept, Authorization, MCP-Protocol-Version, " "Mcp-Session-Id, Mcp-Method, Mcp-Name" ), - "Access-Control-Expose-Headers": "MCP-Protocol-Version", + "Access-Control-Expose-Headers": "MCP-Protocol-Version, WWW-Authenticate", } @@ -63,6 +64,25 @@ async def handle_http(server: MCPServer, context: Any) -> Any: if method == "OPTIONS": return res.text("", 204, _merge_headers(CORS_HEADERS)) + path = getattr(req, "path", None) or "/" + scheme = getattr(req, "scheme", None) or "https" + host = getattr(req, "host", None) or headers.get("host", "appwrite").split(":")[0] + resource = oauth.resource(host) + + if method == "GET" and path.startswith(oauth.METADATA_PATH) and auth_mode() == "oauth": + return res.json(oauth.metadata(resource), 200, _merge_headers(CORS_HEADERS)) + + if method == "GET" and path == oauth.CONSENT_PATH and auth_mode() == "oauth": + return res.text( + oauth.consent_page(), + 200, + { + "content-type": "text/html; charset=utf-8", + "content-security-policy": "frame-ancestors 'none'", + "x-frame-options": "DENY", + }, + ) + # Stateless: no SSE GET stream, no session DELETE if method in ("GET", "DELETE"): body = _jsonrpc_error( @@ -85,29 +105,6 @@ async def handle_http(server: MCPServer, context: Any) -> Any: _merge_headers(CORS_HEADERS, {"Allow": "POST, OPTIONS"}), ) - ok, auth_err = check_auth(headers) - if not ok and auth_err is not None: - return res.json( - auth_err["body"], - auth_err["status"], - _merge_headers(CORS_HEADERS, auth_err.get("headers") or {}), - ) - - accept = headers.get("accept", "") - if ( - accept - and "application/json" not in accept - and "text/event-stream" not in accept - and "*/*" not in accept - and os.environ.get("MCP_DEBUG") - ): - context.log(f"Unusual Accept header: {accept}") - - raw = _read_body(req) - path = getattr(req, "path", None) or "/" - scheme = getattr(req, "scheme", None) or "https" - host = getattr(req, "host", None) or headers.get("host", "appwrite").split(":")[0] - try: timeout = float(os.environ.get("MCP_TOOL_TIMEOUT") or "25") if timeout <= 0: @@ -115,19 +112,34 @@ async def handle_http(server: MCPServer, context: Any) -> Any: except ValueError: timeout = 25.0 - try: - status, out_headers, payload = await asyncio.wait_for( - dispatch( - server, - method=method, - path=path, - headers=headers, - body=raw, - scheme=scheme, - host=host, - ), - timeout=timeout, + async def authorize_and_dispatch() -> dict[str, Any] | tuple[int, dict[str, str], Any]: + ok, auth_err, verified = await asyncio.to_thread(check_auth, headers, resource) + if not ok and auth_err is not None: + return auth_err + oauth.verified_claims.set(verified) + + accept = headers.get("accept", "") + if ( + accept + and "application/json" not in accept + and "text/event-stream" not in accept + and "*/*" not in accept + and os.environ.get("MCP_DEBUG") + ): + context.log(f"Unusual Accept header: {accept}") + + return await dispatch( + server, + method=method, + path=path, + headers=headers, + body=_read_body(req), + scheme=scheme, + host=host, ) + + try: + result = await asyncio.wait_for(authorize_and_dispatch(), timeout=timeout) except asyncio.TimeoutError: err = _jsonrpc_error( None, @@ -137,6 +149,14 @@ async def handle_http(server: MCPServer, context: Any) -> Any: ) return res.json(err, 504, _merge_headers(CORS_HEADERS)) + if isinstance(result, dict): + return res.json( + result["body"], + result["status"], + _merge_headers(CORS_HEADERS, result.get("headers") or {}), + ) + + status, out_headers, payload = result merged = _merge_headers(CORS_HEADERS, out_headers or {}) if not payload: return res.text("", status, merged)