Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions python/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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://<your-function>.appwrite.run/oauth/consent` and your scopes, e.g. `openid tasks.read`.
2. Add a **Web platform** for `<your-function>.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:<id>")`.

Clients register themselves (dynamic client registration), so the connect step is just the URL.

### Smoke test

```bash
Expand Down Expand Up @@ -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

Expand All @@ -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 <token>`.
Auth gate for the endpoint. `none` (default) is open; `bearer` requires `Authorization: Bearer <token>`; `oauth` requires an access token from the project's Appwrite OAuth2 server.

| Question | Answer |
| ------------ | -------------- |
Expand All @@ -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://<request host>`; 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 `<APPWRITE_FUNCTION_API_ENDPOINT>/oauth2/<project ID>`.

| Question | Answer |
| ------------ | --------------------------------------------------- |
| Required | No |
| Sample Value | `https://fra.cloud.appwrite.io/v1/oauth2/<project>` |

### MCP_TOOL_TIMEOUT

Soft deadline (seconds) for the whole request, before Appwrite's 30s domain hard-cap.
Expand Down
2 changes: 2 additions & 0 deletions python/mcp-server/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
mcp>=2.0.0,<3.0.0
PyJWT[crypto]>=2.10,<3
appwrite>=24,<25
31 changes: 29 additions & 2 deletions python/mcp-server/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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}
46 changes: 32 additions & 14 deletions python/mcp-server/src/appwrite_mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -15,15 +19,18 @@ 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)
when auth fails; None when ok.
"""
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, {
Expand All @@ -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:
Expand All @@ -52,27 +59,38 @@ 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",
"id": None,
"error": {"code": -32001, "message": message},
},
"status": 401,
"headers": {"WWW-Authenticate": "Bearer"},
"headers": {"WWW-Authenticate": challenge},
}
137 changes: 137 additions & 0 deletions python/mcp-server/src/appwrite_mcp/consent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize access</title>
<style>
:root { --bg: #f4f4f7; --card: #fff; --text: #19191c; --muted: #6c6c71; --border: #e4e4e7; --accent: #fd366e; --accent-text: #fff; }
@media (prefers-color-scheme: dark) { :root { --bg: #111113; --card: #1d1d21; --text: #ededf0; --muted: #97979b; --border: #2d2d31; } }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif; padding: 16px; }
main { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; }
h1 { font-size: 20px; margin: 0 0 4px; }
p { margin: 0 0 16px; color: var(--muted); }
label { display: block; font-size: 13px; margin: 12px 0 4px; }
input { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--text); font: inherit; }
button { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font: inherit; font-weight: 500; cursor: pointer; margin-top: 12px; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
ul { list-style: none; margin: 0 0 8px; padding: 0; border: 1px solid var(--border); border-radius: 8px; }
li { padding: 10px 12px; border-top: 1px solid var(--border); font-family: ui-monospace, Menlo, monospace; font-size: 13px; }
li:first-child { border-top: 0; }
.meta { font-size: 13px; color: var(--muted); margin: 12px 0 0; word-break: break-all; }
.error { color: #e5484d; font-size: 13px; margin-top: 12px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<section id="login" hidden>
<h1>Sign in to continue</h1>
<p id="login-hint">An app wants to connect to your account.</p>
<form id="login-form">
<label for="email">Email</label>
<input id="email" type="email" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" required>
<button class="primary" type="submit">Sign in</button>
</form>
</section>

<section id="consent" hidden>
<h1>Authorize <span id="client"></span></h1>
<p>Signed in as <strong id="user"></strong>. This app is asking for:</p>
<ul id="scopes"></ul>
<p class="meta" id="resource"></p>
<button class="primary" id="approve">Allow</button>
<button id="reject">Deny</button>
</section>

<p class="error" id="error" hidden></p>
</main>

<script src="https://cdn.jsdelivr.net/npm/appwrite@^28.1.0/dist/iife/sdk.js"></script>
<script>
const endpoint = '__APPWRITE_ENDPOINT__';
const project = '__APPWRITE_PROJECT__';
const client = new Appwrite.Client().setEndpoint(endpoint).setProject(project);
const account = new Appwrite.Account(client);
const params = new URLSearchParams(location.search);
const oauth2 = (path) => new URL(`${endpoint}/oauth2/${project}${path}`);
const json = { 'content-type': 'application/json', accept: 'application/json' };
const $ = (id) => document.getElementById(id);

function fail(error) {
$('error').textContent = error.message || String(error);
$('error').hidden = false;
}

let grantId = params.get('grant_id');
let signedInHere = false;

function showLogin(hint) {
if (hint) $('login-hint').textContent = hint;
$('consent').hidden = true;
$('login').hidden = false;
}

async function authorize() {
const url = oauth2('/authorize');
for (const key of ['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method', 'prompt', 'max_age', 'authorization_details', 'request_uri']) {
if (params.get(key)) url.searchParams.set(key, params.get(key));
}
for (const value of params.getAll('resource')) url.searchParams.append('resource', value);
return client.call('get', url, json);
}

async function showConsent(user) {
if (!grantId) {
const response = await authorize();
if (response.redirectUrl) {
location.assign(response.redirectUrl);
return;
}
grantId = response.grantId;
}
const details = await client.call('get', oauth2(`/grants/${grantId}`), json);

if ((details.prompt || '').split(' ').includes('login') && !signedInHere) {
await account.deleteSession({ sessionId: 'current' });
showLogin('Sign in again to continue.');
return;
}

$('client').textContent = new URL(details.redirectUri).host;
$('user').textContent = user.email;
$('scopes').replaceChildren(...details.scopes.map((scope) => Object.assign(document.createElement('li'), { textContent: scope })));
$('resource').textContent = details.resources.length ? `For: ${details.resources.join(', ')}` : '';
$('login').hidden = true;
$('consent').hidden = false;

for (const [button, path] of [['approve', '/approve'], ['reject', '/reject']]) {
$(button).onclick = async () => {
try {
const response = await client.call('post', oauth2(path), json, { grant_id: grantId });
location.assign(response.redirectUrl);
} catch (error) {
fail(error);
}
};
}
}

$('login-form').onsubmit = async (event) => {
event.preventDefault();
try {
await account.createEmailPasswordSession({ email: $('email').value, password: $('password').value });
signedInHere = true;
await showConsent(await account.get());
} catch (error) {
fail(error);
}
};

account.get().then(showConsent, () => showLogin()).catch(fail);
</script>
</body>
</html>
Loading
Loading