Skip to content

Support MCP events in the Appwrite MCP server #127

Open
appwrite/appwrite
#14466

Description

@ChiragAgg5k

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 to support_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 is md5(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 payloadSchema fields and never forward the raw body.
  • Appwrite webhooks filter only by event name; status filters are applied at our ingress.
  • Executions served through function domains fire no event, so execution.failed does 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. ⚠️ The Free limit appears to be enforced only on the /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/discover at 2026-07-28 returns capabilities.events (both shapes); events/list returns the v1 catalog (6 events), filtered to what the caller can access.
  • events/subscribe validates arguments, secret and URL; authorizes; runs the verification handshake (cached); returns a deterministic id, refreshBefore, cursor: null and truncated: 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/unsubscribe stops 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.json for mcp_events_subscriptions_total, mcp_events_deliveries_total, mcp_events_ingress_total (covers Plan step 9 observability panels).

Activity

  1. self-assigned this
    on Oct 8, 2026
  2. ChiragAgg5k commented on Oct 13, 2026

    @ChiragAgg5k
    MemberAuthor

    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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions