Repository navigation
Support MCP events in the Appwrite MCP server #127
Description
Goal
Let ChatGPT (and other MCP clients) subscribe to things that happen in a user's Appwrite project and receive a signed webhook when they occur.
"When a function execution fails in project X, read the logs and open a fix."
"When a row is added tosupport_tickets, triage it."
Scope: hosted mcp.appwrite.io (Streamable HTTP + OAuth) only. v1 is webhook delivery only, because that is all ChatGPT supports. Local stdio is out of scope.
Specs: OpenAI: MCP Events · WG design sketch (merged 2026-09-08) · SEP-3415 Events Extension (draft, opened 2026-10-05)
Blockers
| Blocker | Why | Status |
|---|---|---|
appwrite/appwrite#14293: encrypted webhook httpPass column is sized for plaintext |
The sealed subscription envelope lives in the webhook's authPassword. Today that field fails above ~99 chars (randomly from ~90) with a 500, reproduced on prod. We will not work around it by putting state in authUsername or the URL. |
Open; must ship before this lands |
How it works
ChatGPT mcp.appwrite.io (stateless, any replica) Appwrite project
─────── ──────────────────────────────────────── ────────────────
│ server/discover ───────▶ capabilities.events
│ events/list ───────▶ catalog (6 events)
│
│ events/subscribe ───────▶ 1. validate args, https URL, whsec_ secret
│ (bearer token, 2. authorize: read target resource ─────────▶ GET table/function/bucket
│ url, secret) 3. verify callback ◀─── challenge POST ───┐
│ 4. upsert webhook (id = subscription id) ───▶ POST/PUT /webhooks
│ authPassword = sealed envelope │
│ ◀── {id, refreshBefore} (nothing stored on our side) │
│ │
│ row created ───┤
│ ingress /appwrite/webhooks/{id} ◀──────────────┘ Basic auth = envelope
│ 5. verify HMAC, open envelope, check expiry + filter, ACK
│ ◀── signed POST ───────── 6. strip to IDs, Standard Webhooks sign, retry in-process
│
│ events/subscribe (again, before refreshBefore) → re-check access, new envelope
│ events/unsubscribe ─────▶ DELETE /webhooks/{id}
Where the state lives
No database. Each subscription is one Appwrite webhook in the user's project, and everything needed to deliver is sealed inside it:
| Webhook field | Content |
|---|---|
$id |
Deterministic subscription id: refresh = upsert, unsubscribe = delete by id |
authPassword |
Sealed envelope (AES-GCM, server key, versioned): callback URL, whsec_ secret(s), event name, filters, expiry, project id. Appwrite sends it back as Basic auth on every delivery. Encrypted at rest and write-only on Appwrite's side. Requires appwrite/appwrite#14293. |
secret |
Appwrite signing key, derived from HMAC(server key, subscription id) so the ingress can verify without storage |
events |
Narrowest Appwrite pattern for the subscription (filtering happens in Appwrite) |
url |
https://mcp.appwrite.io/appwrite/webhooks/{id} |
Any replica can serve any request or delivery, and restarts lose nothing. Verified end to end on a local Cloud stack with real OAuth tokens (spike branch spike/events-stateless): two replicas, restart, expiry, refresh, rotation, unsubscribe.
Spec cheat sheet
Methods
| Method | Params | Result |
|---|---|---|
events/list |
{cursor?} |
{events: [{name, description, delivery: ["webhook"], inputSchema, payloadSchema}], nextCursor?} |
events/subscribe |
{name, arguments, delivery: {mode: "webhook", url, secret}, cursor, ttlMs?, maxAgeMs?} |
{id, refreshBefore, cursor, truncated, deliveryStatus?} |
events/unsubscribe |
{name, arguments, delivery: {url}} |
{} (idempotent, even when nothing matched) |
All results carry resultType: "complete" (2026-07-28 protocol).
Subscribe rules
| Rule | Detail |
|---|---|
| Subscription id | Deterministic SHA-256 over (principal, url, name, canonical-JSON arguments). Subscribe is an idempotent upsert. |
| URL | https only |
| Secret | whsec_ + base64 that decodes to 24–64 bytes |
| TTL | We grant short TTLs: default 1h, floor 5m, cap 24h. Never grant refreshBefore: null. |
| Cursor | Always null (Appwrite webhooks have no replay). truncated: false. |
| Verification | Signed POST {"type":"verification","challenge":…} with webhook-id: msg_verification_<rand>. The receiver must echo the challenge in a 2xx; compare it in constant time. Cache per (principal, url). |
Delivery
| Item | Value |
|---|---|
| Body | {eventId, name, timestamp, data, cursor: null}, one event per POST, ≤ 256 KiB |
| Headers | webhook-id (= eventId), webhook-timestamp, webhook-signature: v1,<b64 HMAC-SHA256(id.ts.body)>, X-MCP-Subscription-Id |
| Retries | 3–5 attempts within ≤ 15 min, exponential backoff, re-signed each attempt. No retry on 410 or 413. |
| Secret rotation | Dual-sign (space-separated signatures) |
| SSRF | https only; resolve DNS ourselves; block non-global IPs; connect to the checked IP while keeping SNI/Host; no redirects. Also applies to verification POSTs. |
Error codes
The codes are being renumbered (SEP-3415 / WG PR #11), so keep them behind constants. Ship the ChatGPT values for now.
| Meaning | ChatGPT / sketch | SEP-3415 (draft) |
|---|---|---|
| Bad args / URL / secret | -32602 |
-32602 |
NotFound (data.kind) |
-32011 |
-32023 |
| Forbidden | -32012 |
-32024 |
ResourceExhausted (data.limit, data.max) |
-32013 |
-32025 |
| Unsupported | -32014 |
-32026 |
CallbackEndpointError (data.reason) |
-32015 |
-32027 |
data.reason ∈ challenge_failed, timeout, connection_refused, tls_error, http_4xx, http_5xx.
Capability advertisement: send both
| Where | Who reads it |
|---|---|
capabilities.events: {} |
ChatGPT today |
capabilities.extensions["io.modelcontextprotocol/events"]: {"listChanged": false} |
SEP-3415 (future clients) |
Event catalog
Every event takes a required project_id. Payloads carry IDs and metadata only (ids, status, timestamps, error code); the agent fetches details with appwrite_call_tool.
Who benefits
| Appwrite user | What they can hand off to ChatGPT | Events |
|---|---|---|
| Backend / Functions developer | "When an execution fails, read the logs, find the bug, open a PR" | functions.execution.failed |
| Anyone shipping Functions or Sites | "When a build fails, diagnose it and suggest a fix" / "when it's live, smoke-test it" | functions.deployment.completed, sites.deployment.completed |
| Product teams with app data (tickets, orders, leads, feedback) | "Triage new support tickets", "flag risky orders", "summarize new feedback daily" | tablesdb.row.created |
| Apps with user uploads | "Summarize, classify or check uploaded documents" | storage.file.created |
| Founders / growth | "Enrich and welcome new signups", "notify me about signups from company domains" | users.user.created |
v1
| MCP event | Arguments | Appwrite event pattern |
|---|---|---|
functions.execution.failed |
function_id |
functions.{fn}.executions.*.update (async, cron, event-triggered) and .create (sync runs fire only .create), filtered at ingress to status = failed |
functions.deployment.completed |
function_id, status? (ready | failed) |
functions.{fn}.deployments.*.update, filtered to terminal status |
sites.deployment.completed |
site_id, status? (ready | failed) |
sites.{site}.deployments.*.update, filtered to terminal status |
tablesdb.row.created |
database_id, table_id |
tablesdb.{db}.tables.{table}.rows.*.create |
storage.file.created |
bucket_id |
buckets.{bucket}.files.*.create |
users.user.created |
none | users.*.create (payload: user_id, created_at only) |
Execution failures are a separate event, not execution.completed with a filter: successful executions are high-volume noise for an agent.
Later
| MCP event | Appwrite pattern | Why it waits |
|---|---|---|
tablesdb.row.updated / deleted |
rows.*.update / .delete |
High volume. Agents that write rows trigger themselves, so the feedback-loop risk is worst here. |
documentsdb.document.created |
documentsdb.{db}.collections.{c}.documents.*.create |
Same shape as rows; add once rows prove out |
teams.membership.created |
teams.{team}.memberships.*.create |
Moderate value |
messaging.message.failed |
messages.*.update |
Needs payload filtering |
users.user.blocked |
users.*.update.status |
Rare; security-flavored |
proxy.rule.created / deleted |
rules.*.create / .delete |
Domain changes; audit value only |
Excluded
| Group | Reason |
|---|---|
users.*.sessions.*, recovery, verification, tokens, update.password / .mfa / .email |
Auth/PII events. Pushing them into an LLM is a security and privacy liability. |
Schema changes (tables.*, columns.*, indexes.*, attributes.*, databases.*.create) |
Developer actions, usually done by the agent itself |
vectorsdb.* |
Too new and niche for v1 |
functions.*.executions.*.create alone |
Fires with waiting for async runs, so it isn't a completion signal |
schedules.*, reports.*, topics.*, providers.*, targets.* |
Configuration churn with no obvious agent action |
Legacy aliases databases.*.collections.*.documents.* |
They duplicate tablesdb.*. Subscribe to only one name or we deliver twice. |
Notes
eventId= hash(X-Appwrite-Webhook-Delivery-Id, subscription id). The delivery id ismd5(eventId:webhookId)and is identical on every Appwrite retry, so dedup is stable.- Appwrite payloads are the full response model: whole rows, and users with email, phone and prefs. The ingress must project them down to the
payloadSchemafields and never forward the raw body. - Appwrite webhooks filter only by event name;
statusfilters are applied at our ingress. - Executions served through function domains fire no event, so
execution.faileddoes not cover HTTP traffic to function domains.
Current footprint
| Area | Today | Impact |
|---|---|---|
| SDK | mcp 2.0.0 (latest 2.3.0); no events support in any release |
ServerCapabilities drops events (python-sdk#3640). Workaround is verified: a Server.middleware that patches the server/discover result after call_next reaches the wire. add_request_handler("events/…") works. The SDK's in-memory test client also drops the key, so tests assert on raw HTTP. |
| Server | build_mcp_server() in server.py |
Register the 3 handlers and the middleware here |
| HTTP | Starlette app, RequireBearer(MCPIdentityMiddleware(...)) on / and /mcp |
Add an unauthenticated ingress route (signature-checked) next to /.well-known/openai-apps-challenge. Start the delivery worker in the lifespan task group. |
| Auth | Short-lived OAuth access tokens only; no refresh token | The server can only act on Appwrite at subscribe/refresh time |
| State | None. Production mcp (europe-west3, HPA 2–4, chart generic 1.23.0) has no Redis, Dragonfly or Postgres. |
Stays stateless: state is sealed into the user's webhook (see Where the state lives). Only new secret: the sealing key in Parameter Store. |
| Appwrite webhooks | /v1/webhooks (alias /v1/projects/:id/webhooks); scopes webhooks.read / webhooks.write. OAuth tokens get project:webhooks.*, and we already advertise them. Worker: HMAC-SHA1 signature, 15s timeout, broker retries (NATS: 30s/120s/600s, max 5), disabled after 10 consecutive failures + email to org owners. Headers include X-Appwrite-Webhook-Events, -Project-Id, -Delivery-Id. |
The ingress must always ACK fast |
| Plan limits | Webhooks per project: Free 2, Start 50, Pro/Scale unlimited | One webhook per subscription; Free plans hit the limit at 2 → -32013 with an upgrade message |
| Deps | httpx already present |
Sign with stdlib hmac (~20 lines) or add standardwebhooks |
Plan
| # | Step | Notes |
|---|---|---|
| 1 | Event source | Appwrite project webhooks → MCP ingress → fan-out. One webhook per subscription (id = subscription id), created with tls: true (the default is false). Never touch the user's own webhooks. Rejected: Realtime (needs a user session plus a socket per subscription), polling (no change feed). |
| 2 | Catalog | events.py: declarative catalog with JSON Schemas + unit tests |
| 3 | Protocol plumbing | events/list, events/subscribe, events/unsubscribe via add_request_handler; capability middleware (both shapes); error-code constants; telemetry + Sentry tags like tools/call; upstream PR for python-sdk#3640 |
| 4 | Sealed envelope | AES-GCM envelope in authPassword with a version byte; sealing key from Parameter Store; derived Appwrite signing key. Blocked on appwrite/appwrite#14293. |
| 5 | Authorization | On subscribe/refresh, read the target resource with the caller's token, or return Forbidden. Refresh doubles as the periodic access re-check. |
| 6 | SSRF-safe egress + verification | Custom httpx transport (IP pinning, no redirects, 10s timeout, per-host rate limit) + handshake |
| 7 | Ingress + delivery | Verify Appwrite HMAC → open envelope → check expiry and filter → ACK fast (Appwrite times out at 15s). Expired, unknown or non-matching events still return 2xx so Appwrite doesn't pause the webhook. Sign and retry in-process (a pod crash mid-retry loses that event: at-most-once, same as Appwrite). |
| 8 | Unsubscribe / cleanup | Delete the webhook by id with the caller's token. On every subscribe, also delete that caller's expired MCP webhooks in the project, so stale ones don't use up Free-plan slots. Expired subscriptions have no token → drop their deliveries and clean up on the next authenticated request, or with an App installation token (OQ3). |
| 9 | Observability | Metrics: subscriptions, verifications, deliveries by status, retries, ingress→delivery latency |
| 10 | Docs + tests | Unit (schemas, id canonicalization, whsec_, SSRF ranges/rebinding/redirects, signature vectors, Appwrite HMAC), staging integration, ChatGPT checklist, docs/events.md |
| 11 | Rollout | Flag in flags.py → staging → ChatGPT Work chat → production → rescan plugin |
Risks
| Risk | Mitigation |
|---|---|
| Spec is a draft (capability location and error codes already moving) | Thin, isolated protocol layer; constants; advertise both capability shapes |
| SDK shim on a provisional middleware API | Pin the SDK, add a raw-HTTP test, remove once python-sdk#3640 lands |
| Side effects in user projects (webhook quota, console noise, orphans; Appwrite pauses after 10 failures and emails the owner) | Name managed webhooks clearly; ingress always ACKs; cleanup path (OQ3) |
| Revoked access keeps delivering until the next refresh | Short TTL cap bounds the window |
| No replay (Appwrite webhooks are best-effort) | cursor: null; document at-most-once |
| SSRF / flooding / secrets at rest | Pinned egress, per-host limits, encryption at rest |
| Prompt injection / PII via payloads (Appwrite sends full documents) | Project to IDs/metadata only at ingress |
Feedback loops (agent writes rows that re-trigger row.created) |
Document; test the burst case |
| State lives in a user-owned resource (a user editing or deleting the webhook ends the subscription) | GCM integrity check rejects edits; ChatGPT re-subscribes on refresh |
| Blocked on an Appwrite fix | appwrite/appwrite#14293 tracked as a blocker; no workaround |
Open questions
| # | Question | Status |
|---|---|---|
| 1 | Can the OAuth token manage project webhooks? | ✅ Yes. project:webhooks.write is in Cloud's OAuth scope allowlist, and AccessTokenKey maps it to an API key. Requires the token's authorization_details to grant the project and the user to be a confirmed org member. We advertise it through scopes_supported, but users can narrow it at consent, so return Forbidden with a clear message when it's missing. |
| 2 | Webhook plan limits? | ✅ Free 2, Start 50, Pro/Scale unlimited → one webhook per subscription; at the limit return -32013 with an upgrade-to-Pro message. /v1/projects/:id/webhooks path, not on /v1/webhooks (ProjectsLimit.php); worth fixing in Cloud separately. |
| 3 | Cleanup of orphaned webhooks without a user token? | 🟡 No internal API, and webhooks have no "managed" marker. Best candidate: an App installation. POST /v1/apps/:appId/installations/:installationId/tokens mints user-less tokens with the installation's scopes and org grant, which could delete webhooks. Needs a decision on whether MCP should become an App. Fallback: clean up on the principal's next authenticated request. |
| 4 | Which event marks a terminal execution? | ✅ Async, cron and event-triggered → executions.*.update from the worker. Sync → only .create, fired after completion with the final status. Function-domain executions → no event. |
| 5 | Shared store/queue for mcp? |
✅ Not needed. The spec only requires durability for no-expiry grants, which we never give; replicas share state through the sealed envelope. |
| 6 | Allowlist ChatGPT callback hosts, or always run the handshake? | Open. Recommend always running the handshake. |
| 7 | Who owns the OpenAI plugin record for mcp.appwrite.io? |
Open |
| 8 | Poll/push for stdio later? | Deferred. Not needed for ChatGPT. |
Done when
- Encrypted and length-limited attributes return 500 instead of 400 (webhooks authPassword/authUsername/url, variables value) appwrite#14293 is shipped to Cloud.
-
server/discoverat2026-07-28returnscapabilities.events(both shapes);events/listreturns the v1 catalog (6 events), filtered to what the caller can access. -
events/subscribevalidates arguments, secret and URL; authorizes; runs the verification handshake (cached); returns a deterministicid,refreshBefore,cursor: nullandtruncated: false; idempotent across refresh and rotation. - A matching Appwrite change → exactly one Standard-Webhooks-signed POST ≤ 256 KiB. Non-matching changes → no POST.
- Retries are bounded and re-signed; no retry on
410/413; works with 2+ replicas and survives a pod restart with no shared store. - SSRF tests pass (private, loopback, link-local, IPv6 ULA, DNS rebinding, redirects).
-
events/unsubscribestops delivery and removes the managed webhook; expired subscriptions stop and get cleaned up. - ChatGPT checklist passes on staging, then production, behind a flag.
-
docs/events.md, README, metrics and Sentry tags are in place.
2026-10-12 — monitoring dashboards
- appwrite-labs/monitoring#605: Events row on
dashboards/Cloud/mcp.jsonformcp_events_subscriptions_total,mcp_events_deliveries_total,mcp_events_ingress_total(covers Plan step 9 observability panels).
Activity
ChiragAgg5k commentedon Oct 13, 2026
2026-10-13 (board sync): Linked open PRs under this umbrella — appwrite/mcp#139 (push delivery, Closes #137) and appwrite-labs/application-configuration#576 (staging Dragonfly for MCP Events). Both assigned to ChiragAgg5k; board Status left In progress (GraphQL unavailable this morning).