From a162616b168cfdd68c3e239c19e5b7e03a3dcff7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 17:37:09 +0100 Subject: [PATCH 01/11] feat(mcp-server): per-user sign-in with Appwrite OAuth2 MCP clients can sign users in through the project's Appwrite OAuth2 server, and tools call Appwrite as that user so row permissions apply. --- python/mcp-server/README.md | 51 +++++++- python/mcp-server/requirements.txt | 2 + python/mcp-server/src/app.py | 15 ++- python/mcp-server/src/appwrite_mcp/auth.py | 27 +++- .../mcp-server/src/appwrite_mcp/consent.html | 123 ++++++++++++++++++ python/mcp-server/src/appwrite_mcp/oauth.py | 91 +++++++++++++ .../mcp-server/src/appwrite_mcp/transport.py | 21 ++- 7 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 python/mcp-server/src/appwrite_mcp/consent.html create mode 100644 python/mcp-server/src/appwrite_mcp/oauth.py diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index 1eba6ec2..d1710f9c 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_rows | `database_id: string`, `table_id: string` | rows the signed-in user can read (`oauth` mode) | ### Connect a client @@ -113,6 +122,17 @@ 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). Tools call Appwrite as that user via `oauth.client(ctx.headers)`, so table and row permissions apply. + +1. Enable the project's OAuth2 server with **Authorization URL** `https://.appwrite.run/oauth/consent` and scopes such as `openid project:rows.read`. +2. Add a **Web platform** for `.appwrite.run` so the consent page can call Appwrite. +3. Set `MCP_AUTH_MODE=oauth` and `MCP_AUTH_SCOPES=openid project:rows.read`. +4. Give users access with row or table permissions, e.g. `read("user:")`. + +Clients register themselves (dynamic client registration), so the connect step is just the URL. + ### Smoke test ```bash @@ -167,7 +187,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 +203,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 project:rows.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..d8ae778c 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -4,8 +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 -ephemeral API key from inbound headers: +To call Appwrite APIs as the signed-in user (``MCP_AUTH_MODE=oauth``), inject +``ctx: Context`` and use ``oauth.client(ctx.headers)`` — see ``list_rows`` below. +Otherwise read the ephemeral API key from inbound headers: from mcp.server.mcpserver import Context @@ -19,7 +20,10 @@ def list_users(ctx: Context) -> dict: import os -from mcp.server.mcpserver import MCPServer +from appwrite.services.tables_db import TablesDB +from mcp.server.mcpserver import Context, MCPServer + +from appwrite_mcp import oauth server = MCPServer( name=os.environ.get("MCP_SERVER_NAME") or "appwrite-hosted-mcp", @@ -39,3 +43,8 @@ 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 rows the signed-in user can read (needs MCP_AUTH_MODE=oauth).") +def list_rows(database_id: str, table_id: str, ctx: Context) -> dict: + return TablesDB(oauth.client(ctx.headers)).list_rows(database_id, table_id).to_dict() diff --git a/python/mcp-server/src/appwrite_mcp/auth.py b/python/mcp-server/src/appwrite_mcp/auth.py index 723b099f..aae21be9 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]: """ Returns (ok, error_payload). error_payload is a dict suitable for context.res.json(..., 401, headers) @@ -25,6 +29,9 @@ def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: if mode in ("", "none", "open", "false", "0"): return True, None + if mode == "oauth": + return _check_oauth(headers, resource) + if mode != "bearer": return False, { "body": { @@ -66,7 +73,21 @@ def check_auth(headers: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: return True, None -def _unauthorized(message: str) -> dict[str, Any]: +def _check_oauth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None]: + auth = (headers.get("authorization") or "").strip() + prefix = "Bearer " + if not auth.startswith(prefix): + return False, _unauthorized("Missing or invalid Authorization header", oauth.challenge(resource)) + + try: + oauth.verify(auth[len(prefix) :].strip(), resource) + except jwt.PyJWTError as error: + return False, _unauthorized(f"Invalid access token: {error}", oauth.challenge(resource, "invalid_token")) + + return True, None + + +def _unauthorized(message: str, challenge: str = "Bearer") -> dict[str, Any]: return { "body": { "jsonrpc": "2.0", @@ -74,5 +95,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..8b304f47 --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/consent.html @@ -0,0 +1,123 @@ + + + + + +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..f94a9dfd --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -0,0 +1,91 @@ +# OAuth 2.1 resource-server side of MCP auth, backed by the project's Appwrite OAuth2 server. + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import jwt +from appwrite.client import Client + +METADATA_PATH = "/.well-known/oauth-protected-resource" +CONSENT_PATH = "/oauth/consent" + +_jwks: jwt.PyJWKClient | None = 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 challenge(resource_url: str, error: str | None = None) -> str: + parts = [f'resource_metadata="{resource_url}{METADATA_PATH}"'] + 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", cache_keys=True) + + if jwt.get_unverified_header(token).get("typ", "").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 client(headers: Any) -> Client: + """Appwrite client acting as the user who authorized the MCP client; permissions apply to every call.""" + authorization = (headers or {}).get("authorization", "") + return ( + Client() + .set_endpoint(os.environ["APPWRITE_FUNCTION_API_ENDPOINT"]) + .set_project(os.environ["APPWRITE_FUNCTION_PROJECT_ID"]) + .add_header("Authorization", authorization) + ) diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py index 00800c56..3bb0c3b6 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,17 @@ 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"}) + # Stateless: no SSE GET stream, no session DELETE if method in ("GET", "DELETE"): body = _jsonrpc_error( @@ -85,7 +97,7 @@ async def handle_http(server: MCPServer, context: Any) -> Any: _merge_headers(CORS_HEADERS, {"Allow": "POST, OPTIONS"}), ) - ok, auth_err = check_auth(headers) + ok, auth_err = check_auth(headers, resource) if not ok and auth_err is not None: return res.json( auth_err["body"], @@ -104,9 +116,6 @@ async def handle_http(server: MCPServer, context: Any) -> Any: 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") From 41f6e1203ab4e4cdfdbca67fd0126d1ba0cb3156 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 21:57:02 +0100 Subject: [PATCH 02/11] fix(mcp-server): harden OAuth consent page and token checks Deny framing of the consent page so it can't be clickjacked, verify tokens off the event loop with a bounded JWKS fetch, and treat a non-string typ header as an invalid token instead of a 500. --- python/mcp-server/src/appwrite_mcp/oauth.py | 6 ++++-- python/mcp-server/src/appwrite_mcp/transport.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py index f94a9dfd..1c89f4fa 100644 --- a/python/mcp-server/src/appwrite_mcp/oauth.py +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -11,6 +11,7 @@ METADATA_PATH = "/.well-known/oauth-protected-resource" CONSENT_PATH = "/oauth/consent" +JWKS_TIMEOUT = 5 _jwks: jwt.PyJWKClient | None = None @@ -65,9 +66,10 @@ 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", cache_keys=True) + _jwks = jwt.PyJWKClient(f"{issuer()}/.well-known/jwks.json", cache_keys=True, timeout=JWKS_TIMEOUT) - if jwt.get_unverified_header(token).get("typ", "").lower() != "at+jwt": + 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( diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py index 3bb0c3b6..4acdcca0 100644 --- a/python/mcp-server/src/appwrite_mcp/transport.py +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -73,7 +73,15 @@ async def handle_http(server: MCPServer, context: Any) -> Any: 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"}) + 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"): @@ -97,7 +105,7 @@ async def handle_http(server: MCPServer, context: Any) -> Any: _merge_headers(CORS_HEADERS, {"Allow": "POST, OPTIONS"}), ) - ok, auth_err = check_auth(headers, resource) + ok, auth_err = await asyncio.to_thread(check_auth, headers, resource) if not ok and auth_err is not None: return res.json( auth_err["body"], From f33aa7bad29a85e980c2def4fcfba3e087a13ead Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 Oct 2026 22:38:59 +0100 Subject: [PATCH 03/11] fix(mcp-server): stop caching JWKS signing keys indefinitely PyJWKClient's per-key cache never expires, so a key removed from the project's JWKS kept being trusted until the worker restarted; the JWK-set cache already refreshes every 300s. --- python/mcp-server/src/appwrite_mcp/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py index 1c89f4fa..c7965dd4 100644 --- a/python/mcp-server/src/appwrite_mcp/oauth.py +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -66,7 +66,7 @@ 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", cache_keys=True, timeout=JWKS_TIMEOUT) + _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": From 05177ec7e916b862b206b3e3a2142858582cf6c2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 10:32:38 +0100 Subject: [PATCH 04/11] fix(mcp-server): honour prompt=login and include auth in the request deadline The consent page now ends the session and requires a fresh sign-in when the grant asks for reauthentication (prompt=login or expired max_age), token verification counts against MCP_TOOL_TIMEOUT, and the Web SDK uses a compatible ^28.1.0 range. --- .../mcp-server/src/appwrite_mcp/consent.html | 48 +++++++++----- .../mcp-server/src/appwrite_mcp/transport.py | 66 ++++++++++--------- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/python/mcp-server/src/appwrite_mcp/consent.html b/python/mcp-server/src/appwrite_mcp/consent.html index 8b304f47..ba503514 100644 --- a/python/mcp-server/src/appwrite_mcp/consent.html +++ b/python/mcp-server/src/appwrite_mcp/consent.html @@ -28,7 +28,7 @@
- + diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py index 4acdcca0..9eb7b2e2 100644 --- a/python/mcp-server/src/appwrite_mcp/transport.py +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -105,26 +105,6 @@ async def handle_http(server: MCPServer, context: Any) -> Any: _merge_headers(CORS_HEADERS, {"Allow": "POST, OPTIONS"}), ) - ok, auth_err = await asyncio.to_thread(check_auth, headers, resource) - 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) - try: timeout = float(os.environ.get("MCP_TOOL_TIMEOUT") or "25") if timeout <= 0: @@ -132,19 +112,33 @@ 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 = await asyncio.to_thread(check_auth, headers, resource) + if not ok and auth_err is not None: + return auth_err + + 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, @@ -154,6 +148,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) From c0afafdc2532ab730855d8ab708f9ede7ddc9ecf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 11:08:19 +0100 Subject: [PATCH 05/11] feat(mcp-server): gate tools on app-defined scopes and act as the user via JWT The access token now carries only the app's own scopes (e.g. tasks.read) and never calls Appwrite directly; tools check their scope and read as the signed-in user through a short-lived JWT minted with the function key, so row permissions still apply without granting project:* scopes. --- python/mcp-server/README.md | 23 +++++++++---- python/mcp-server/src/app.py | 18 +++++++---- python/mcp-server/src/appwrite_mcp/oauth.py | 36 +++++++++++++++------ 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index d1710f9c..64c48a91 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -85,7 +85,7 @@ Not supported (no SSE streams, no sessions). Returns `405`. | ---- | ---------------- | ---------------- | | echo | `text: string` | echoed string | | add | `a: float`, `b: float` | sum | -| list_rows | `database_id: string`, `table_id: string` | rows the signed-in user can read (`oauth` mode) | +| list_tasks | — | the signed-in user's tasks (`oauth` mode, needs the `tasks.read` scope) | ### Connect a client @@ -124,12 +124,13 @@ 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). Tools call Appwrite as that user via `oauth.client(ctx.headers)`, so table and row permissions apply. +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 scopes such as `openid project:rows.read`. +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. Set `MCP_AUTH_MODE=oauth` and `MCP_AUTH_SCOPES=openid project:rows.read`. -4. Give users access with row or table permissions, e.g. `read("user:")`. +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. @@ -173,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 @@ -210,7 +212,7 @@ Space-separated scopes advertised to MCP clients when `MCP_AUTH_MODE=oauth`. | Question | Answer | | ------------ | --------------------------- | | Required | No | -| Sample Value | `openid project:rows.read` | +| Sample Value | `openid tasks.read` | ### MCP_RESOURCE @@ -230,6 +232,15 @@ OAuth2 issuer. Defaults to `/oauth2/ | Required | No | | Sample Value | `https://fra.cloud.appwrite.io/v1/oauth2/` | +### TASKS_DATABASE_ID / TASKS_TABLE_ID + +Table read by the example `list_tasks` tool. + +| Question | Answer | +| ------------ | ---------------- | +| Required | No | +| Sample Value | `main` / `tasks` | + ### MCP_TOOL_TIMEOUT Soft deadline (seconds) for the whole request, before Appwrite's 30s domain hard-cap. diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index d8ae778c..32da66c8 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -4,9 +4,10 @@ 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 as the signed-in user (``MCP_AUTH_MODE=oauth``), inject -``ctx: Context`` and use ``oauth.client(ctx.headers)`` — see ``list_rows`` below. -Otherwise read the ephemeral API key from inbound headers: +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 @@ -25,6 +26,9 @@ def list_users(ctx: Context) -> dict: from appwrite_mcp import oauth +TASKS_DATABASE_ID = os.environ.get("TASKS_DATABASE_ID") or "main" +TASKS_TABLE_ID = os.environ.get("TASKS_TABLE_ID") or "tasks" + server = MCPServer( name=os.environ.get("MCP_SERVER_NAME") or "appwrite-hosted-mcp", version="0.1.0", @@ -45,6 +49,8 @@ def add(a: float, b: float) -> float: return a + b -@server.tool(description="List rows the signed-in user can read (needs MCP_AUTH_MODE=oauth).") -def list_rows(database_id: str, table_id: str, ctx: Context) -> dict: - return TablesDB(oauth.client(ctx.headers)).list_rows(database_id, table_id).to_dict() +@server.tool(description="List the signed-in user's tasks.") +def list_tasks(ctx: Context) -> list[dict]: + oauth.require_scope(ctx.headers, "tasks.read") + rows = TablesDB(oauth.user_client(ctx.headers)).list_rows(TASKS_DATABASE_ID, TASKS_TABLE_ID) + return [{"id": row["$id"], **row["data"]} for row in rows.to_dict()["rows"]] diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py index c7965dd4..ed0abcfa 100644 --- a/python/mcp-server/src/appwrite_mcp/oauth.py +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -8,10 +8,13 @@ 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 @@ -82,12 +85,27 @@ def verify(token: str, resource_url: str) -> dict[str, Any]: ) -def client(headers: Any) -> Client: - """Appwrite client acting as the user who authorized the MCP client; permissions apply to every call.""" - authorization = (headers or {}).get("authorization", "") - return ( - Client() - .set_endpoint(os.environ["APPWRITE_FUNCTION_API_ENDPOINT"]) - .set_project(os.environ["APPWRITE_FUNCTION_PROJECT_ID"]) - .add_header("Authorization", authorization) - ) +def claims(headers: Any) -> dict[str, Any]: + """Claims of the request's access token. The transport verified this exact token before any tool runs.""" + token = (headers or {}).get("authorization", "").removeprefix("Bearer ").strip() + return jwt.decode(token, options={"verify_signature": False}) + + +def require_scope(headers: Any, scope: str) -> None: + """Fail the tool call unless the user granted this app-defined scope (e.g. ``tasks.read``).""" + if scope not in claims(headers).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(headers)["sub"], duration=USER_JWT_DURATION) + return Client().set_endpoint(endpoint).set_project(project).set_jwt(user_jwt.jwt) From 4c1ae2aae17a03ffe2fc93b947d162699bcf9fb3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 11:14:18 +0100 Subject: [PATCH 06/11] fix(mcp-server): only trust transport-verified token claims in tools Tool helpers decoded the Authorization header without verification, so in none/bearer mode a forged JWT could pass require_scope and make user_client mint a real JWT for any user. The transport now hands the claims it verified to the request context, and the helpers refuse without them. --- python/mcp-server/src/app.py | 2 +- python/mcp-server/src/appwrite_mcp/auth.py | 24 +++++++++---------- python/mcp-server/src/appwrite_mcp/oauth.py | 19 +++++++++------ .../mcp-server/src/appwrite_mcp/transport.py | 3 ++- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index 32da66c8..c6aaa9e5 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -51,6 +51,6 @@ def add(a: float, b: float) -> float: @server.tool(description="List the signed-in user's tasks.") def list_tasks(ctx: Context) -> list[dict]: - oauth.require_scope(ctx.headers, "tasks.read") + oauth.require_scope("tasks.read") rows = TablesDB(oauth.user_client(ctx.headers)).list_rows(TASKS_DATABASE_ID, TASKS_TABLE_ID) return [{"id": row["$id"], **row["data"]} for row in rows.to_dict()["rows"]] diff --git a/python/mcp-server/src/appwrite_mcp/auth.py b/python/mcp-server/src/appwrite_mcp/auth.py index aae21be9..615f7fd9 100644 --- a/python/mcp-server/src/appwrite_mcp/auth.py +++ b/python/mcp-server/src/appwrite_mcp/auth.py @@ -19,7 +19,7 @@ def expected_token() -> str: return os.environ.get("MCP_AUTH_TOKEN") or "" -def check_auth(headers: dict[str, str], resource: 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) @@ -27,7 +27,7 @@ def check_auth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, """ 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) @@ -44,7 +44,7 @@ def check_auth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, }, "status": 500, "headers": {}, - } + }, None token = expected_token() if not token: @@ -59,32 +59,32 @@ def check_auth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, }, "status": 500, "headers": {}, - } + }, None auth = (headers.get("authorization") or "").strip() prefix = "Bearer " if not auth.startswith(prefix): - return False, _unauthorized("Missing or invalid Authorization header") + return False, _unauthorized("Missing or invalid Authorization header"), None provided = auth[len(prefix) :].strip() if not hmac.compare_digest(provided, token): - return False, _unauthorized("Invalid bearer token") + return False, _unauthorized("Invalid bearer token"), None - return True, None + return True, None, None -def _check_oauth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None]: +def _check_oauth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None, dict[str, Any] | None]: auth = (headers.get("authorization") or "").strip() prefix = "Bearer " if not auth.startswith(prefix): - return False, _unauthorized("Missing or invalid Authorization header", oauth.challenge(resource)) + return False, _unauthorized("Missing or invalid Authorization header", oauth.challenge(resource)), None try: - oauth.verify(auth[len(prefix) :].strip(), resource) + verified = oauth.verify(auth[len(prefix) :].strip(), resource) except jwt.PyJWTError as error: - return False, _unauthorized(f"Invalid access token: {error}", oauth.challenge(resource, "invalid_token")) + 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, challenge: str = "Bearer") -> dict[str, Any]: diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py index ed0abcfa..6e6b00d4 100644 --- a/python/mcp-server/src/appwrite_mcp/oauth.py +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from contextvars import ContextVar from pathlib import Path from typing import Any @@ -18,6 +19,8 @@ _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") @@ -85,15 +88,17 @@ def verify(token: str, resource_url: str) -> dict[str, Any]: ) -def claims(headers: Any) -> dict[str, Any]: - """Claims of the request's access token. The transport verified this exact token before any tool runs.""" - token = (headers or {}).get("authorization", "").removeprefix("Bearer ").strip() - return jwt.decode(token, options={"verify_signature": False}) +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(headers: Any, scope: str) -> None: +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(headers).get("scope", "").split(): + if scope not in claims().get("scope", "").split(): raise ToolError(f"Missing scope: {scope}") @@ -107,5 +112,5 @@ def user_client(headers: Any) -> Client: 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(headers)["sub"], duration=USER_JWT_DURATION) + 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 9eb7b2e2..bf522816 100644 --- a/python/mcp-server/src/appwrite_mcp/transport.py +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -113,9 +113,10 @@ async def handle_http(server: MCPServer, context: Any) -> Any: timeout = 25.0 async def authorize_and_dispatch() -> dict[str, Any] | tuple[int, dict[str, str], Any]: - ok, auth_err = await asyncio.to_thread(check_auth, headers, resource) + 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 ( From 02618bfa501d9d23725b25143449216e8c515b91 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 11:26:11 +0100 Subject: [PATCH 07/11] fix(mcp-server): paginate list_tasks instead of truncating at 25 rows list_rows defaults to a 25-row page, so larger task lists were silently cut off; the tool now takes limit/cursor and returns a typed page with next_cursor. --- python/mcp-server/README.md | 2 +- python/mcp-server/src/app.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index 64c48a91..f3cc99be 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -85,7 +85,7 @@ Not supported (no SSE streams, no sessions). Returns `405`. | ---- | ---------------- | ---------------- | | echo | `text: string` | echoed string | | add | `a: float`, `b: float` | sum | -| list_tasks | — | the signed-in user's tasks (`oauth` mode, needs the `tasks.read` scope) | +| 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 diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index c6aaa9e5..0da88aa5 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -20,7 +20,9 @@ def list_users(ctx: Context) -> dict: from __future__ import annotations import os +from typing import Any, TypedDict +from appwrite.query import Query from appwrite.services.tables_db import TablesDB from mcp.server.mcpserver import Context, MCPServer @@ -29,6 +31,11 @@ def list_users(ctx: Context) -> dict: TASKS_DATABASE_ID = os.environ.get("TASKS_DATABASE_ID") or "main" TASKS_TABLE_ID = os.environ.get("TASKS_TABLE_ID") or "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", version="0.1.0", @@ -49,8 +56,13 @@ def add(a: float, b: float) -> float: return a + b -@server.tool(description="List the signed-in user's tasks.") -def list_tasks(ctx: Context) -> list[dict]: +@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") - rows = TablesDB(oauth.user_client(ctx.headers)).list_rows(TASKS_DATABASE_ID, TASKS_TABLE_ID) - return [{"id": row["$id"], **row["data"]} for row in rows.to_dict()["rows"]] + 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 = [{"id": row["$id"], **row["data"]} for row in rows] + return {"tasks": tasks, "next_cursor": tasks[-1]["id"] if len(tasks) == page_size else None} From 62ee7284a113bfff98e06daf6bfa03cde3bd294a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 12:22:28 +0100 Subject: [PATCH 08/11] refactor(mcp-server): keep example table IDs in code instead of env variables They only configure the example list_tasks tool, which developers replace with their own tools. --- python/mcp-server/README.md | 9 --------- python/mcp-server/src/app.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index f3cc99be..6b0376ed 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -232,15 +232,6 @@ OAuth2 issuer. Defaults to `/oauth2/ | Required | No | | Sample Value | `https://fra.cloud.appwrite.io/v1/oauth2/` | -### TASKS_DATABASE_ID / TASKS_TABLE_ID - -Table read by the example `list_tasks` tool. - -| Question | Answer | -| ------------ | ---------------- | -| Required | No | -| Sample Value | `main` / `tasks` | - ### MCP_TOOL_TIMEOUT Soft deadline (seconds) for the whole request, before Appwrite's 30s domain hard-cap. diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index 0da88aa5..81ef4825 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -28,8 +28,8 @@ def list_users(ctx: Context) -> dict: from appwrite_mcp import oauth -TASKS_DATABASE_ID = os.environ.get("TASKS_DATABASE_ID") or "main" -TASKS_TABLE_ID = os.environ.get("TASKS_TABLE_ID") or "tasks" +TASKS_DATABASE_ID = "main" +TASKS_TABLE_ID = "tasks" class TaskPage(TypedDict): From e95a3e7e1a75e27d42723ae8e1e3f9de00c41996 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 12:29:02 +0100 Subject: [PATCH 09/11] fix(mcp-server): keep the row ID authoritative in list_tasks A table with its own id column overwrote the row ID, which next_cursor relies on. --- python/mcp-server/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py index 81ef4825..2510d683 100644 --- a/python/mcp-server/src/app.py +++ b/python/mcp-server/src/app.py @@ -64,5 +64,5 @@ def list_tasks(ctx: Context, limit: int = 25, cursor: str | None = None) -> Task 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 = [{"id": row["$id"], **row["data"]} for row in 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} From ca4bb194832e0f2a5341fccf0d00d60fc02b3fea Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 12:33:33 +0100 Subject: [PATCH 10/11] fix(mcp-server): advertise RFC 9728 path-inserted metadata URLs With MCP_RESOURCE carrying a path (e.g. /mcp), the challenge pointed at /mcp/.well-known/oauth-protected-resource, which isn't served, so clients couldn't discover the auth server. --- python/mcp-server/src/appwrite_mcp/oauth.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/python/mcp-server/src/appwrite_mcp/oauth.py b/python/mcp-server/src/appwrite_mcp/oauth.py index 6e6b00d4..6beb5728 100644 --- a/python/mcp-server/src/appwrite_mcp/oauth.py +++ b/python/mcp-server/src/appwrite_mcp/oauth.py @@ -6,6 +6,7 @@ from contextvars import ContextVar from pathlib import Path from typing import Any +from urllib.parse import urlsplit import jwt from appwrite.client import Client @@ -59,8 +60,14 @@ def metadata(resource_url: str) -> dict[str, Any]: 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="{resource_url}{METADATA_PATH}"'] + parts = [f'resource_metadata="{metadata_url(resource_url)}"'] if scopes(): parts.append(f'scope="{" ".join(scopes())}"') if error: From beffaf611619b485bec11863acd54d474b22e7cf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 5 Oct 2026 12:49:37 +0100 Subject: [PATCH 11/11] fix(mcp-server): accept the Bearer scheme case-insensitively HTTP auth scheme names are case-insensitive (RFC 9110/6750), so clients sending 'bearer' were rejected without verification in oauth and bearer modes. --- python/mcp-server/src/appwrite_mcp/auth.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/python/mcp-server/src/appwrite_mcp/auth.py b/python/mcp-server/src/appwrite_mcp/auth.py index 615f7fd9..ad191bb1 100644 --- a/python/mcp-server/src/appwrite_mcp/auth.py +++ b/python/mcp-server/src/appwrite_mcp/auth.py @@ -61,26 +61,23 @@ def check_auth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, "headers": {}, }, None - auth = (headers.get("authorization") or "").strip() - prefix = "Bearer " - if not auth.startswith(prefix): + 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 - provided = auth[len(prefix) :].strip() - if not hmac.compare_digest(provided, token): + if not hmac.compare_digest(provided.strip(), token): return False, _unauthorized("Invalid bearer token"), None return True, None, None def _check_oauth(headers: dict[str, str], resource: str) -> tuple[bool, dict[str, Any] | None, dict[str, Any] | None]: - auth = (headers.get("authorization") or "").strip() - prefix = "Bearer " - if not auth.startswith(prefix): + 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 try: - verified = oauth.verify(auth[len(prefix) :].strip(), resource) + 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