From 30cded2ee06683065c76d4084a367a6c4be44ca8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 1 Aug 2026 17:00:27 +0530 Subject: [PATCH 1/5] feat: add Python MCP server template Stateless MCP over Appwrite Functions using the official Python SDK (mcp==2.0.0) with a buffered Streamable HTTP adapter. Also teach the README table generator to title-case "mcp" as "MCP". Closes #352 Co-authored-by: Cursor --- .../markdown-table-workflow/index.js | 1 + python/mcp-server/.gitignore | 163 ++++++++++ python/mcp-server/README.md | 202 ++++++++++++ python/mcp-server/requirements.txt | 1 + python/mcp-server/src/app.py | 41 +++ .../mcp-server/src/appwrite_mcp/__init__.py | 5 + python/mcp-server/src/appwrite_mcp/auth.py | 78 +++++ .../mcp-server/src/appwrite_mcp/dispatch.py | 292 ++++++++++++++++++ .../mcp-server/src/appwrite_mcp/transport.py | 140 +++++++++ python/mcp-server/src/main.py | 19 ++ 10 files changed, 942 insertions(+) create mode 100644 python/mcp-server/.gitignore create mode 100644 python/mcp-server/README.md create mode 100644 python/mcp-server/requirements.txt create mode 100644 python/mcp-server/src/app.py create mode 100644 python/mcp-server/src/appwrite_mcp/__init__.py create mode 100644 python/mcp-server/src/appwrite_mcp/auth.py create mode 100644 python/mcp-server/src/appwrite_mcp/dispatch.py create mode 100644 python/mcp-server/src/appwrite_mcp/transport.py create mode 100644 python/mcp-server/src/main.py diff --git a/.github/workflows/markdown-table-workflow/index.js b/.github/workflows/markdown-table-workflow/index.js index a03813f2..866016ad 100644 --- a/.github/workflows/markdown-table-workflow/index.js +++ b/.github/workflows/markdown-table-workflow/index.js @@ -50,6 +50,7 @@ const overrideWords = { been: "been", am: "am", perspectiveapi: "PerspectiveAPI", + mcp: "MCP", pdf: "PDF", chatgpt: "ChatGPT", fcm: "FCM", diff --git a/python/mcp-server/.gitignore b/python/mcp-server/.gitignore new file mode 100644 index 00000000..beb0967e --- /dev/null +++ b/python/mcp-server/.gitignore @@ -0,0 +1,163 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md new file mode 100644 index 00000000..bbdc1f53 --- /dev/null +++ b/python/mcp-server/README.md @@ -0,0 +1,202 @@ +# 🔌 MCP Server + +Stateless [Model Context Protocol](https://modelcontextprotocol.io/) server on Appwrite Functions — official Python SDK (`mcp==2.0.0`), JSON-RPC over HTTPS. No SSE sessions. + +Edit `src/app.py` to register your own tools, deploy, and point Claude Code / Cursor at the function domain. + +## 🧰 Usage + +### POST / + +Accepts MCP Streamable HTTP JSON requests (legacy handshake and modern `2026-07-28`). + +**Headers** + +| Name | Description | Location | Type | Sample Value | +| ---------------------- | ------------------------------------------------ | -------- | ------ | -------------- | +| 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 | + +**Response** + +Sample `200` — `initialize`: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { "tools": { "listChanged": true } }, + "serverInfo": { "name": "appwrite-hosted-mcp", "version": "0.1.0" } + } +} +``` + +Sample `200` — `tools/call` echo: + +```json +{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "content": [{ "type": "text", "text": "pong" }] + } +} +``` + +Sample `202` — notifications (empty body): + +```text +(empty) +``` + +Sample `401` — bearer auth failure: + +```json +{ + "jsonrpc": "2.0", + "id": null, + "error": { "code": -32001, "message": "Invalid bearer token" } +} +``` + +### OPTIONS / + +CORS preflight. Returns `204` with `Access-Control-Allow-*` headers. + +### GET /, DELETE / + +Not supported (no SSE streams, no sessions). Returns `405`. + +### Demo tools + +| Tool | Arguments | Returns | +| ---- | ---------------- | ---------------- | +| echo | `text: string` | echoed string | +| add | `a: float`, `b: float` | sum | + +### Connect a client + +After deploy, add the function domain: + +```bash +claude mcp add --transport http my-mcp https://.appwrite.run +``` + +Or in Cursor / Claude Desktop `mcp.json`: + +```json +{ + "mcpServers": { + "my-mcp": { + "url": "https://.appwrite.run" + } + } +} +``` + +With bearer auth, set Function env `MCP_AUTH_MODE=bearer` + `MCP_AUTH_TOKEN=...` and pass: + +```json +{ + "mcpServers": { + "my-mcp": { + "url": "https://.appwrite.run", + "headers": { + "Authorization": "Bearer your-long-random-secret" + } + } + } +} +``` + +### Smoke test + +```bash +curl -sS -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.1.0"}}}' \ + https://.appwrite.run +``` + +### Write a tool + +```python +# src/app.py +from mcp.server.mcpserver import MCPServer + +server = MCPServer(name="my-mcp", version="0.1.0") + +@server.tool(description="Do something useful.") +def my_tool(query: str) -> str: + return f"got: {query}" +``` + +Type hints become the tool `inputSchema`. Add `ctx: Context` to read inbound HTTP headers (including Appwrite's dynamic API key `x-appwrite-key`). + +Do **not** name the tools module `server.py` — Open Runtimes already ships a top-level `server` module. + +### Why this isn't `streamable_http_app()` + +Appwrite Functions are short-lived request/response workers. They do not run a Starlette lifespan, so `MCPServer.streamable_http_app()` raises `RuntimeError: Task group is not initialized`. This template drives the SDK's lower-level buffered entry points instead (legacy `serve_one` + modern `handle_modern_request`). Pin `mcp==2.0.0` exactly — those helpers are private and can move. + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | --------------------------------- | +| Runtime | Python (3.12) | +| Entrypoint | `src/main.py` | +| Build Commands | `pip install -r requirements.txt` | +| Permissions | `any` | +| Timeout (Seconds) | 30 | + +## 🔒 Environment Variables + +### MCP_SERVER_NAME + +Display name returned in `initialize` → `serverInfo.name`. + +| Question | Answer | +| ------------ | --------------------- | +| Required | No | +| Sample Value | `appwrite-hosted-mcp` | + +### MCP_AUTH_MODE + +Auth gate for the endpoint. `none` (default) is open; `bearer` requires `Authorization: Bearer `. + +| Question | Answer | +| ------------ | -------------- | +| Required | No | +| Sample Value | `none` | + +### MCP_AUTH_TOKEN + +Shared secret when `MCP_AUTH_MODE=bearer`. Compared with `hmac.compare_digest`. + +| Question | Answer | +| ------------ | ------------------- | +| Required | Yes (when bearer) | +| Sample Value | `s3cr3t...token` | + +### MCP_TOOL_TIMEOUT + +Soft deadline (seconds) for the whole request, before Appwrite's 30s domain hard-cap. + +| Question | Answer | +| ------------ | ------ | +| Required | No | +| Sample Value | `25` | + +### MCP_DEBUG + +Set to `1` to keep exception detail in tool `isError` results and log unusual `Accept` headers. + +| Question | Answer | +| ------------ | ------ | +| Required | No | +| Sample Value | `1` | diff --git a/python/mcp-server/requirements.txt b/python/mcp-server/requirements.txt new file mode 100644 index 00000000..321fd1e3 --- /dev/null +++ b/python/mcp-server/requirements.txt @@ -0,0 +1 @@ +mcp==2.0.0 diff --git a/python/mcp-server/src/app.py b/python/mcp-server/src/app.py new file mode 100644 index 00000000..c22965a4 --- /dev/null +++ b/python/mcp-server/src/app.py @@ -0,0 +1,41 @@ +""" +Example hosted MCP tools for Appwrite Functions. + +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 +dynamic API key from inbound headers: + + from mcp.server.mcpserver import Context + + @server.tool(description="List users (needs users.read scope).") + def list_users(ctx: Context) -> dict: + api_key = (ctx.headers or {}).get("x-appwrite-key") + ... +""" + +from __future__ import annotations + +import os + +from mcp.server.mcpserver import MCPServer + +server = MCPServer( + name=os.environ.get("MCP_SERVER_NAME") or "appwrite-hosted-mcp", + version="0.1.0", + instructions=( + "Stateless MCP on Appwrite Functions. " + "Tools must finish within ~25s (30s domain hard-cap)." + ), +) + + +@server.tool(description="Echo text back — verifies the MCP transport works end-to-end.") +def echo(text: str) -> str: + return text + + +@server.tool(description="Add two numbers.") +def add(a: float, b: float) -> float: + return a + b diff --git a/python/mcp-server/src/appwrite_mcp/__init__.py b/python/mcp-server/src/appwrite_mcp/__init__.py new file mode 100644 index 00000000..20b1c1a6 --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/__init__.py @@ -0,0 +1,5 @@ +"""Appwrite Functions adapter around the official MCP Python SDK.""" + +from .transport import handle_http + +__all__ = ["handle_http"] diff --git a/python/mcp-server/src/appwrite_mcp/auth.py b/python/mcp-server/src/appwrite_mcp/auth.py new file mode 100644 index 00000000..723b099f --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/auth.py @@ -0,0 +1,78 @@ +# Optional bearer-token gate for the hosted MCP endpoint. + +from __future__ import annotations + +import hmac +import os +from typing import Any + + +def auth_mode() -> str: + return (os.environ.get("MCP_AUTH_MODE") or "none").strip().lower() + + +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]: + """ + 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 + + if mode != "bearer": + return False, { + "body": { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": -32000, + "message": f"Unsupported MCP_AUTH_MODE: {mode}", + }, + }, + "status": 500, + "headers": {}, + } + + token = expected_token() + if not token: + return False, { + "body": { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": -32000, + "message": "MCP_AUTH_MODE=bearer but MCP_AUTH_TOKEN is empty", + }, + }, + "status": 500, + "headers": {}, + } + + auth = (headers.get("authorization") or "").strip() + prefix = "Bearer " + if not auth.startswith(prefix): + return False, _unauthorized("Missing or invalid Authorization header") + + provided = auth[len(prefix) :].strip() + if not hmac.compare_digest(provided, token): + return False, _unauthorized("Invalid bearer token") + + return True, None + + +def _unauthorized(message: str) -> dict[str, Any]: + return { + "body": { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32001, "message": message}, + }, + "status": 401, + "headers": {"WWW-Authenticate": "Bearer"}, + } diff --git a/python/mcp-server/src/appwrite_mcp/dispatch.py b/python/mcp-server/src/appwrite_mcp/dispatch.py new file mode 100644 index 00000000..55a8d88d --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/dispatch.py @@ -0,0 +1,292 @@ +"""Two-leg MCP dispatcher for Appwrite Functions (buffered request/response). + +Appwrite never runs a Starlette lifespan, so ``StreamableHTTPSessionManager`` +is unusable here. Instead we route on the ``MCP-Protocol-Version`` header: + +* modern (``2026-07-28``) → ``handle_modern_request`` (ASGI-shaped, no task group) +* legacy handshake eras → ``serve_one`` + born-ready ``Connection.from_envelope`` + +Both legs work with a fresh event loop per request and with Appwrite's +persistent gunicorn worker loop. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio +from mcp.server._streamable_http_modern import handle_modern_request +from mcp.server.connection import Connection +from mcp.server.lowlevel import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.runner import modern_error_data, serve_one +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.message import ServerMessageMetadata +from mcp.shared.transport_context import TransportContext +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS +from starlette.requests import Request + +_FALLBACK_LEGACY_VERSION = "2025-06-18" +_SECURITY = TransportSecuritySettings(enable_dns_rebinding_protection=False) + + +@dataclass +class BufferedDispatchContext: + """Structural ``DispatchContext`` for one buffered request. + + Back-channel is closed by construction — Appwrite cannot hold an SSE stream + open for server-initiated requests or progress notifications. + """ + + transport: TransportContext + request_id: Any + message_metadata: Any + progress_token: Any = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + can_send_request: bool = field(default=False, init=False) + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: Any = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify( + self, + method: str, + params: Mapping[str, Any] | None, + opts: Any = None, + ) -> None: + return None + + async def progress( + self, + progress: float, + total: float | None = None, + message: str | None = None, + ) -> None: + return None + + +def _lowlevel(server: MCPServer) -> Server[Any]: + return server._lowlevel_server # noqa: SLF001 — public API is ASGI-only + + +def _asgi_scope( + *, + method: str, + path: str, + headers: dict[str, str], + body: bytes, + scheme: str = "https", + host: str = "appwrite", +) -> dict[str, Any]: + raw = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()] + raw.append((b"content-length", str(len(body)).encode())) + return { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": scheme, + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "headers": raw, + "client": ("0.0.0.0", 0), + "server": (host, 443 if scheme == "https" else 80), + "state": {}, + } + + +def _sanitize_tool_error_body(body: bytes) -> bytes: + """Strip exception detail from isError tool results unless MCP_DEBUG is set.""" + if os.environ.get("MCP_DEBUG"): + return body + try: + payload = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return body + if not isinstance(payload, dict): + return body + result = payload.get("result") + if not isinstance(result, dict) or not result.get("isError"): + return body + content = result.get("content") + if not isinstance(content, list) or not content: + return body + first = content[0] + if not isinstance(first, dict) or first.get("type") != "text": + return body + text = first.get("text") or "" + if not text.startswith("Error executing tool "): + return body + # "Error executing tool boom: kaboom" → keep tool name, drop detail + head, _, _rest = text.partition(": ") + first["text"] = f"{head}: tool failed" + return json.dumps(payload, separators=(",", ":")).encode() + + +async def _serve_modern( + server: MCPServer, + scope: dict[str, Any], + body: bytes, +) -> tuple[int, dict[str, str], bytes]: + delivered = False + + async def receive() -> dict[str, Any]: + nonlocal delivered + if not delivered: + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + # Buffered runtime — never disconnect mid-handler. + await asyncio.sleep(3600) + return {"type": "http.disconnect"} + + cap: dict[str, Any] = {"status": 500, "headers": {}, "body": b""} + + async def send(message: dict[str, Any]) -> None: + if message["type"] == "http.response.start": + cap["status"] = message["status"] + cap["headers"] = { + k.decode("latin-1"): v.decode("latin-1") for k, v in message["headers"] + } + elif message["type"] == "http.response.body": + cap["body"] += message.get("body", b"") + + # json_response=True is required: False can commit text/event-stream after 15s. + await handle_modern_request( + _lowlevel(server), + _SECURITY, + True, + None, + scope, + receive, + send, + ) + return cap["status"], cap["headers"], _sanitize_tool_error_body(cap["body"]) + + +async def _serve_legacy( + server: MCPServer, + scope: dict[str, Any], + message: dict[str, Any], + protocol_version: str, +) -> tuple[int, dict[str, str], bytes]: + request_id = message.get("id") + if request_id is None: + # Notification (e.g. notifications/initialized) → 202 empty. + return 202, {}, b"" + + request = Request(scope) + connection = Connection.from_envelope( + protocol_version, + {"name": "legacy-client", "version": "0"}, + {}, + ) + dctx = BufferedDispatchContext( + transport=TransportContext( + kind="streamable-http", + can_send_request=False, + headers=request.headers, + ), + request_id=request_id, + message_metadata=ServerMessageMetadata(request_context=request), + ) + try: + result = await serve_one( + _lowlevel(server), + dctx, + message.get("method"), + message.get("params"), + connection=connection, + lifespan_state=None, + ) + payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "result": result} + except Exception as exc: # noqa: BLE001 — map to JSON-RPC error + error = modern_error_data(exc) + payload = { + "jsonrpc": "2.0", + "id": request_id, + "error": error.model_dump(mode="json", by_alias=True, exclude_none=True), + } + + body = json.dumps(payload, separators=(",", ":")).encode() + return 200, {"content-type": "application/json"}, _sanitize_tool_error_body(body) + + +async def dispatch( + server: MCPServer, + *, + method: str, + path: str, + headers: dict[str, str], + body: bytes, + scheme: str = "https", + host: str = "appwrite", +) -> tuple[int, dict[str, str], bytes]: + """Route one buffered HTTP request to the correct MCP protocol leg. + + Returns ``(status, response_headers, body_bytes)``. + """ + headers = {k.lower(): v for k, v in headers.items()} + # Modern entry hard-requires these; synthesize if the client omitted them. + headers.setdefault("accept", "application/json, text/event-stream") + headers["content-type"] = "application/json" + headers.setdefault("host", host) + + scope = _asgi_scope( + method=method, + path=path or "/", + headers=headers, + body=body, + scheme=scheme, + host=host, + ) + + protocol_version = headers.get("mcp-protocol-version") + if protocol_version is not None and protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS: + return await _serve_modern(server, scope, body) + + try: + message = json.loads(body) if body.strip() else None + except json.JSONDecodeError as exc: + err = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": f"Parse error: {exc}"}, + } + return 400, {"content-type": "application/json"}, json.dumps(err).encode() + + if not isinstance(message, dict): + err = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": "Empty or non-object request body"}, + } + return 400, {"content-type": "application/json"}, json.dumps(err).encode() + + # Batches are not supported on the legacy one-shot path. + negotiated = ( + protocol_version + if protocol_version in HANDSHAKE_PROTOCOL_VERSIONS + else _FALLBACK_LEGACY_VERSION + ) + # Prefer the version the client asked for in initialize params when present. + if message.get("method") == "initialize": + params = message.get("params") or {} + if isinstance(params, dict): + asked = params.get("protocolVersion") + if asked in HANDSHAKE_PROTOCOL_VERSIONS: + negotiated = asked + + return await _serve_legacy(server, scope, message, negotiated) diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py new file mode 100644 index 00000000..512fe0e1 --- /dev/null +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -0,0 +1,140 @@ +# Map Appwrite Function req/res <-> MCP Streamable HTTP (JSON mode). + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any + +from mcp.server.mcpserver import MCPServer + +from .auth import check_auth +from .dispatch import dispatch + +CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS, GET, DELETE", + "Access-Control-Allow-Headers": ( + "Content-Type, Accept, Authorization, MCP-Protocol-Version, " + "Mcp-Session-Id, Mcp-Method, Mcp-Name" + ), + "Access-Control-Expose-Headers": "MCP-Protocol-Version", +} + + +def _merge_headers(*dicts: dict[str, str]) -> dict[str, str]: + out: dict[str, str] = {} + for d in dicts: + out.update(d) + return out + + +def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def _read_body(req: Any) -> bytes: + if hasattr(req, "body_binary") and isinstance(req.body_binary, (bytes, bytearray)): + return bytes(req.body_binary) + raw = getattr(req, "body_text", None) + if isinstance(raw, str) and raw: + return raw.encode("utf-8") + body_val = getattr(req, "body", None) + if isinstance(body_val, (dict, list)): + return json.dumps(body_val).encode("utf-8") + if isinstance(body_val, str): + return body_val.encode("utf-8") + if isinstance(body_val, (bytes, bytearray)): + return bytes(body_val) + return b"" + + +async def handle_http(server: MCPServer, context: Any) -> Any: + """ + Appwrite Function entry adapter. + Returns a context.res.* dict (caller must ``return`` it). + """ + req = context.req + res = context.res + method = (req.method or "GET").upper() + headers = {k.lower(): v for k, v in (req.headers or {}).items()} + + if method == "OPTIONS": + return res.text("", 204, _merge_headers(CORS_HEADERS)) + + # Stateless: no SSE GET stream, no session DELETE + if method in ("GET", "DELETE"): + body = _jsonrpc_error( + None, + -32000, + f"{method} not supported on this stateless MCP endpoint " + "(JSON-mode Streamable HTTP only; use POST).", + ) + return res.json( + body, + 405, + _merge_headers(CORS_HEADERS, {"Allow": "POST, OPTIONS"}), + ) + + if method != "POST": + body = _jsonrpc_error(None, -32600, f"Unsupported HTTP method: {method}") + return res.json( + body, + 405, + _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] + + timeout = float(os.environ.get("MCP_TOOL_TIMEOUT") or "25") + + 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, + ) + except asyncio.TimeoutError: + err = _jsonrpc_error( + None, + -32000, + f"Request timed out after {timeout}s " + "(Appwrite function domain hard-cap is 30s).", + ) + return res.json(err, 504, _merge_headers(CORS_HEADERS)) + + merged = _merge_headers(CORS_HEADERS, out_headers or {}) + if not payload: + return res.text("", status, merged) + if isinstance(payload, str): + payload = payload.encode("utf-8") + return res.binary(payload, status, merged) diff --git a/python/mcp-server/src/main.py b/python/mcp-server/src/main.py new file mode 100644 index 00000000..231acdee --- /dev/null +++ b/python/mcp-server/src/main.py @@ -0,0 +1,19 @@ +"""Appwrite Function entrypoint — thin adapter around the official MCP SDK.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure sibling modules (app, appwrite_mcp) resolve under Appwrite's entrypoint layout. +# Note: never name a user module `server` — Open Runtimes already ships server.py. +_SRC = Path(__file__).resolve().parent +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from app import server # noqa: E402 +from appwrite_mcp import handle_http # noqa: E402 + + +async def main(context): + return await handle_http(server, context) From a4bbefd6c73baa92e9ec1d3d275214ab9a7a392a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 1 Aug 2026 17:04:00 +0530 Subject: [PATCH 2/5] fix: fall back when MCP_TOOL_TIMEOUT is invalid Non-numeric or non-positive values used to raise ValueError before dispatch and skip an MCP JSON-RPC response. Co-authored-by: Cursor --- python/mcp-server/src/appwrite_mcp/transport.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/mcp-server/src/appwrite_mcp/transport.py b/python/mcp-server/src/appwrite_mcp/transport.py index 512fe0e1..00800c56 100644 --- a/python/mcp-server/src/appwrite_mcp/transport.py +++ b/python/mcp-server/src/appwrite_mcp/transport.py @@ -108,7 +108,12 @@ async def handle_http(server: MCPServer, context: Any) -> Any: scheme = getattr(req, "scheme", None) or "https" host = getattr(req, "host", None) or headers.get("host", "appwrite").split(":")[0] - timeout = float(os.environ.get("MCP_TOOL_TIMEOUT") or "25") + try: + timeout = float(os.environ.get("MCP_TOOL_TIMEOUT") or "25") + if timeout <= 0: + raise ValueError("MCP_TOOL_TIMEOUT must be positive") + except ValueError: + timeout = 25.0 try: status, out_headers, payload = await asyncio.wait_for( From 3053ee69d5bd47c4f090514a664bff82c5c6efc2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 1 Aug 2026 17:08:47 +0530 Subject: [PATCH 3/5] docs: quiet pip in MCP server build command Keeps Appwrite Function build logs to open-runtimes lines instead of full dependency resolution spam. Co-authored-by: Cursor --- python/mcp-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index bbdc1f53..1362dd0e 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -150,7 +150,7 @@ Appwrite Functions are short-lived request/response workers. They do not run a S | ----------------- | --------------------------------- | | Runtime | Python (3.12) | | Entrypoint | `src/main.py` | -| Build Commands | `pip install -r requirements.txt` | +| Build Commands | `pip install -q --disable-pip-version-check -r requirements.txt` | | Permissions | `any` | | Timeout (Seconds) | 30 | From a569c21405421e6f847843b19fe685f68bcf1491 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 1 Aug 2026 17:09:19 +0530 Subject: [PATCH 4/5] Revert "docs: quiet pip in MCP server build command" Keep the verbose pip install build command. Co-authored-by: Cursor --- python/mcp-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index 1362dd0e..bbdc1f53 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -150,7 +150,7 @@ Appwrite Functions are short-lived request/response workers. They do not run a S | ----------------- | --------------------------------- | | Runtime | Python (3.12) | | Entrypoint | `src/main.py` | -| Build Commands | `pip install -q --disable-pip-version-check -r requirements.txt` | +| Build Commands | `pip install -r requirements.txt` | | Permissions | `any` | | Timeout (Seconds) | 30 | From 94f04b93eaa13f3672387b364760ffd98e18d082 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 3 Aug 2026 16:46:45 +0530 Subject: [PATCH 5/5] docs: clarify MCP server purpose in README intro Co-authored-by: Cursor --- python/mcp-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mcp-server/README.md b/python/mcp-server/README.md index bbdc1f53..4224e059 100644 --- a/python/mcp-server/README.md +++ b/python/mcp-server/README.md @@ -1,6 +1,6 @@ # 🔌 MCP Server -Stateless [Model Context Protocol](https://modelcontextprotocol.io/) server on Appwrite Functions — official Python SDK (`mcp==2.0.0`), JSON-RPC over HTTPS. No SSE sessions. +Expose custom tools to AI clients (Claude Code, Cursor, and other MCP hosts) over HTTPS. Stateless [Model Context Protocol](https://modelcontextprotocol.io/) server on Appwrite Functions — official Python SDK (`mcp==2.0.0`), JSON-RPC over HTTPS. No SSE sessions. Edit `src/app.py` to register your own tools, deploy, and point Claude Code / Cursor at the function domain.