Repository navigation
feat: receive Appwrite webhooks and forward MCP events - #134
Open
ChiragAgg5k wants to merge 9 commits into
Open
ChiragAgg5k wants to merge 9 commits into
ChiragAgg5k wants to merge 9 commits into
Conversation
🟢 Tier S · Ready to mergeAdds the Appwrite webhook ingress and event projection, then forwards accepted events through the existing signed callback delivery path. The route is enabled with MCP Events, uses sealed subscription envelopes and bounded reads/backlogs, and includes end-to-end coverage plus documentation and telemetry updates. 1 of 26 changed files was too large to include in full.
Note Part of the diff was too large to review, so Hansi did not approve. 📂 Walkthrough · 16
Reviewed |
ChiragAgg5k
force-pushed
the
feat/events-ingress
branch
from
October 9, 2026 13:53
18e3823 to
7c03f56
Compare
This was referenced Oct 9, 2026
ChiragAgg5k
force-pushed
the
feat/events-ingress
branch
from
October 11, 2026 20:16
7c03f56 to
326d338
Compare
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 20:16
8cc957c to
2d9cf2e
Compare
hansi-codes
Bot
dismissed
their stale review
October 11, 2026 20:26
The findings that requested changes are resolved. See the summary comment for what is still open.
ChiragAgg5k
added a commit
that referenced
this pull request
Oct 11, 2026
Signing, retries, final statuses, TLS and redirect handling, timeouts and per-host limits are covered through the ingress e2e flows (#134). What stays needs a controlled resolver or address table, runs only from subscribe (PR 5), or waits on the production retry schedule.
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 21:02
2d9cf2e to
147d12d
Compare
ChiragAgg5k
force-pushed
the
feat/events-ingress
branch
from
October 11, 2026 21:02
2a428f7 to
6d46b33
Compare
hansi-codes
Bot
dismissed
their stale review
October 11, 2026 21:10
The findings that requested changes are resolved. See the summary comment for what is still open.
delivery.Reason duplicated the CallbackFailure enum from errors.py value for value. Keep one source so the subscribe handler can pass CallbackError.reason straight into EventsError.callback_endpoint.
Mount POST /appwrite/webhooks/{id} behind the events flag. The Appwrite signature authenticates it: the ingress rebuilds the registered URL from MCP_PUBLIC_URL, derives the webhook secret from the envelope's key id, hashes the body as it streams, then opens the envelope for the webhook and project.
Bodies are projected onto the event's payload schema and never forwarded raw. Anything from the subscriber's own webhook gets a 2xx (expired, filtered, unknown, retired key, oversized) so Appwrite never pauses it and emails the owner; only failed authentication gets 401.
Deliveries run in a task group owned by the app lifespan with one shared Egress and Dispatcher, and the request returns at once. Adds mcp.events.ingress and mcp.events.deliveries counters, and drops the section-header comments in telemetry.py.
Replace the TestClient and MockTransport ingress tests with flows through the real hosted server: Appwrite deliveries signed like the webhooks worker, deliveries verified by the official standardwebhooks library at a real HTTPS receiver, and counters read from the server's OTLP export. This also covers the signing, retry, TLS, redirect and per-host limit behavior the delivery and egress unit tests no longer check.
Each accepted webhook starts a delivery that can live for minutes of retries, with no admission bound, so a steady stream of events to failing callbacks could exhaust memory. The ingress now holds at most 1024 deliveries in flight per process and 64 per subscription; past either limit it still answers 200 (Appwrite never pauses the webhook) and drops the event as backlog_full. Two replicas refreshing one subscription during a rolling key rotation can interleave their writes and leave the new key's envelope next to the old key's signing secret, failing every delivery until the next refresh. The signature is now checked against the secret of every key in the ring. That keeps the webhook format and the rotation procedure as they are, unlike a separate non-rotating signing root, which would need a second secret to configure and could never be rotated. The e2e flows also cover what the stack's earlier unit tests did: status filters for completed executions and building deployments, envelopes that are truncated, not base64, or decrypt but do not match the id, foreign subscription ids, and the egress deadline including the per-host slot wait.
The 2 MiB limit only bounded what was kept; the public route kept reading and hashing, under every ring key, until the client closed the stream, so an unauthenticated caller could hold connections and CPU with an endless or stalled body. A request without a signature is now refused before its body is read, and a body past 16 MiB (declared or streamed) or still arriving after 10 seconds is acknowledged as unread and never verified or delivered.
The ingress reads the key id from the JWE header, opens the envelope for this server, the routed webhook id and the Appwrite project header, and treats the profile's expiry as the 2xx expired drop. A subscription carries one delivery secret, so the dual-secret flow goes; the forged-delivery flow now also covers an envelope sealed for another server and one with an unsupported header.
Body reads come before the signature check, so anyone who knows a subscription id and a key id could hold memory and sockets with concurrent forged bodies. A process now reads at most READS (32) bodies at once; a request waits for a slot within its read timeout. Every read ends by its own deadline, so a stalled forger delays real deliveries but cannot starve them.
ChiragAgg5k
added a commit
that referenced
this pull request
Oct 11, 2026
Signing, retries, final statuses, TLS and redirect handling, timeouts and per-host limits are covered through the ingress e2e flows (#134). What stays needs a controlled resolver or address table, runs only from subscribe (PR 5), or waits on the production retry schedule.
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 21:16
147d12d to
656ac20
Compare
ChiragAgg5k
force-pushed
the
feat/events-ingress
branch
from
October 11, 2026 21:16
e389a85 to
9339d89
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
feat/events← #131 ← #132 ← #133 ← #134 ← #135 (events/subscribe/events/unsubscribe)Merges into
feat/events;feat/events→mainlands as one feature after appwrite/appwrite#14293. Each PR's diff shows only its own layer, and every layer passes the full checklist on its own.Summary
Part 4 of the MCP Events work (#127), covering plan step 7 (ingress and delivery). Behind
MCP_EVENTS, the hosted server now receives Appwrite webhooks atPOST /appwrite/webhooks/{id}. It verifies them, opens the sealed subscription, projects the body down to the event's payload fields, and forwards one Standard-Webhooks-signed event to the subscriber.events/subscribe/events/unsubscribeare still not served; they come in PR 5.delivery.Reasonduplicatederrors.CallbackFailurevalue for value.delivery.pynow usesCallbackFailure, andCallbackError.reasonis typed with it, so subscribe can pass it straight toEventsError.callback_endpoint. feat: sign and deliver MCP events safely #133's tests are updated to match.events/projection.py. One projector per catalog event. Each reads only the fields in that event'spayload_schema, so raw bodies never go out. It checks resource IDs against the sealed arguments, matchesX-Appwrite-Webhook-Eventsagainst the subscription's patterns, applies the status filter, and normalizes timestamps.events/ingress.py. The Starlette endpoint and theIngressthat owns the delivery task group, plusIngress.from_env().http_app.py. The route is mounted only whenevents_protocol.enabled("http").ingress.run()joinssession_manager.run()in the lifespan through anAsyncExitStack.build_app(ingress=None)takes an injected ingress for tests. With the flag on and noMCP_EVENTS_SEALING_KEYS, startup fails withKeyringError.envelope.appwrite_hmac(url, key). The HMAC state behindappwrite_signature, so the ingress can hash the body while it streams in.appwrite_signaturenow uses it, and its PHP test vector still passes.mcp.events.ingress(outcome, reason, event) andmcp.events.deliveries(event, outcome, reason). This also removes the# --- … ---section-header comments fromtelemetry.py.Flow
mcp-subscription+jweprofile.envelope.key_idreads thekidfrom its protected header. If there is no Basic auth, the password is not JWE Compact with a validkid, or the id is not a subscription id, the response is401 credentials.200 retired_key. Otherwise the ingress deriveskeyring.signing_key(id, k)for every keykin the ring. It rebuilds the registered URL as{MCP_PUBLIC_URL}/appwrite/webhooks/{id}and ignores the inboundHost. It streams the body intoHMAC-SHA1(url + body)under each key and keeps up to 2 MiB, then compares each result in constant time withX-Appwrite-Webhook-Signature. No match is401 signature. The read is bounded because the route is public:200 dropped unread.READS). A request waits for a slot inside its 10 s read deadline; every read ends by its own deadline, so a stalled forger can delay real deliveries but not starve them.keyring.open(envelope, Context(server=MCP_PUBLIC_URL, tenant=X-Appwrite-Webhook-Project-Id, resource=id)). The expected context comes only from configuration, the routed id and the project header, never from the envelope. A packageEnvelopeError(tampered, not the profile, bound to another server, project or webhook) or a record that does not hash to the id is401 envelope. The package'sExpiredSubscriptionis200 dropped expired.eventId = "evt_" + sha256(canonical_json([X-Appwrite-Webhook-Delivery-Id, subscription id]))[:32], which stays the same across Appwrite retries.timestampis the event time from the body, or the receive time if the body has none. The ingress takes a backlog slot (at most 1024 deliveries in flight per process and 64 per subscription, retries included). It then callstask_group.start_soon(dispatcher.deliver, …)and returns200 {"status":"accepted","eventId":…}straight away. With no slot free, it answers200 dropped backlog_fulland nothing is delivered. A delivery that raises is caught, sent to Sentry and counted, so it never cancels the group.Appwrite-specific mappings (from
spike/events-statelessand the Appwrite source):resourceId;resourceTypemust befunctions. Sync runs fire.createalready terminal and are delivered. Async.createwithwaitingis dropped. Async.updatewithfailedis delivered, even with no$createdAt(created_at: null).ready/failed. Activation (PATCH /functions/:id/deployment) firesdeployments.*.updatewith a function model, which has noresourceType; it is detected by shape and dropped. A duplicate fires with a deployment that is stillwaitingand is dropped.$databaseId/$tableId/bucketId, when present, must equal the sealed argumentsuser_idandcreated_at; no email, phone, name, labels or prefs2026-10-09 09:38:26.634, UTC) or response-model format, normalized to2026-10-09T09:38:26.634Z; missing or invalid givesnullDrop / ACK rules
Appwrite treats any
>= 400as a failure. After 10 failures in a row (_APP_WEBHOOK_MAX_FAILED_ATTEMPTS) it pauses the webhook and emails the org owners (src/Appwrite/Platform/Workers/Webhooks.php). So everything that comes from the subscriber's own webhook gets a200.200 accepted200 dropped expired200 dropped retired_key200 dropped unknown_eventX-Appwrite-Webhook-Eventshas none of the subscription's patterns200 dropped event_mismatch200 dropped resource_mismatchresourceType, no$id)200 dropped shapewaiting,readyvs afailedfilter)200 dropped status200 dropped malformed200 dropped too_large200 dropped unread200 dropped backlog_full401 credentials401 signature401 envelopeWhy a legitimate webhook never gets a 401 in normal operation:
During a rotation the old key stays in the ring for at least the max TTL, so envelopes and secrets written with it still open and verify (tested).
Two replicas can refresh one subscription during a rolling rotation: the old one seals with
k1, the new one withk2. Their writes can interleave (oldPUT, newPUT, newPATCH /secret, oldPATCH /secret) and leave ak2envelope next to ak1-derived secret. The signature is therefore checked against every ring key, not only the envelope's.I picked this over a separate, non-rotating root for the Appwrite secret. It keeps the webhook format and the rotation procedure unchanged, needs no second secret to configure, and costs one extra HMAC per key in a ring that holds one or two keys. A non-rotating root could never be rotated itself.
Once the old key is removed, only expired subscriptions still name it. Their webhooks are orphans waiting for cleanup, so I deliberately answer them with
200 retired_keyinstead of401. This differs from the "bad envelope → 401" rule because it is the one bad-envelope case a real webhook produces in normal operation. Nothing is delivered, and the 200 tells an unauthenticated caller nothing.The 401 response body is only
{"status":"rejected","reason":…}. Appwrite shows failure bodies in the webhook logs in the Console.Changing
MCP_PUBLIC_URLbreaks every existing subscription's signature until it refreshes. This is documented.Oversized bodies are hashed to the end, so a valid signature still gets the 200 drop and a forged one still gets 401, and memory stays bounded.
Tests
tests/unit/test_events_ingress.py(41 tests) is replaced by end-to-end flows through the real server intests/e2e/test_events_ingress.py. The recorded-shape Appwrite fixtures moved totests/e2e/fixtures/appwrite/, unchanged.Harness additions (
tests/e2e/support.py):Appwriteplays the webhooks worker. It sends theX-Appwrite-Webhook-*headers, uses Basic auth only when user and password are both set, and signsX-Appwrite-Webhook-Signature = base64(HMAC-SHA1(url . body, secret))over the webhook's configured URL.X-Appwrite-Webhook-Eventsis generated likeEvent::generateEvents, and the delivery id ismd5(event:webhook). Requests are addressed to the server's bound socket whileMCP_PUBLIC_URLishttps://mcp.e2e.test, like a load balancer in front of the pod.Receiverplays ChatGPT's endpoint: a real HTTPS server onlocalhostwith a self-signed certificate. It checks every request with the officialstandardwebhookslibrary (new test-only dependency in thee2egroup), echoes verification challenges, records SNI, and replays scripted replies (status, delay, redirect).Collectoris an OTLP/HTTP endpoint. Every server exports its real metrics to it, so counters are asserted on what the server emits.force_flushmakes reads immediate.The server under test is the production ingress. The keyring and public URL come from the environment, and it is injected through
build_app(ingress=...)with an egress that allows loopback and trusts the receiver's certificate, a 1 s timeout, and a retry policy of 0.3 s / 0.3 s / 0.6 s. Untilevents/subscribeexists, each test writes the webhook exactly as PR 5 will (sealed envelope asauthPassword, derivedsecret, the event's patterns).E2E flows:
200 acceptedand exactly one POST at the receiver. The POST isstandardwebhooks-verified,webhook-id=eventId= the ingress'seventId,X-MCP-Subscription-Idis set, the timestamp is fresh, and SNI /Hostarelocalhost.datavalidates against thepayloadSchemathe same server publishes onevents/listand has exactly its keys. No email, phone,sk_live_key, build-log secret, card number or prompt-injection text from the fixtures is on the wire. The row event and the users payload (user_id,created_atonly) are checked exactly..create(already failed) is delivered. An async.createwhile waiting, and a completed execution, are dropped (status). An async.update(failed) is delivered with DB-format timestamps normalized andcreated_at: null. A wrongresourceTypeis dropped (shape). A failed deployment is delivered. Activation (function model) is dropped (shape), as is a duplicate (waiting,status) and a deployment still building (status). Thestatusargument narrows delivery: withstatus=failed, a ready deployment is dropped and a failed one is delivered. BadmimeType/sizetypes becomenull, a+02:00time converts to UTC, a body without$idis dropped (shape), and an unparseable time becomesnullwith the event stamped on receipt.eventIdtwice at the receiver. A new delivery, or the same delivery to another subscription, yields a new one.not json,[1, 2]), unknown event, a signed body over 2 MiB (too_large; the same body with a bad signature gets 401), and a retired key (200 retired_key). A projected event over 256 KiB is accepted but never sent.mcp.events.ingress{outcome=dropped}rises by exactly the expected amount per reason, andmcp.events.deliveries{outcome=too_large}by one.MCP_PUBLIC_URL); a JWE naming a ring key with an unsupportedenc; an envelope sealed with the server's key for this webhook and project but holding another subscription's contents (all401 envelope); no Basic auth, a Bearer header, bad base64, an empty password, the oldv1.k1.…format, too many parts, a non-empty encrypted-key segment, a header that is not base64url JSON, a header withoutkid, or an invalidkid(all401 credentials); a path id that is not a subscription id, including Appwrite-validsub_zzzz…/sub_AAAA….mcp.events.ingress{outcome=rejected}rises by 23. GET returns 405. A spoofedHost/X-Forwarded-Hostis ignored and the delivery succeeds.500→ timeout →302→200gives four attempts with the samewebhook-idand newer timestamps, and the redirect target is never requested.410and413get one attempt each.503is abandoned after 4. An untrusted certificate gets no request. Counters:delivered+1,rejected/http_4xx+2,abandoned/http_5xx+1,abandoned/tls_error+1.200 dropped backlog_full. It accepts one for another subscription, then drops the next because the process is full. Finished deliveries free their slots.backlog_fullrises by 2.200 unreadwith nothing delivered: a correctly signed 16 MiB+ body with a declared length, a forged 17 MiB chunked body, and a body that stalls for 1.5 s. While the stalled forgery holds the slot, a real delivery waits over 0.3 s and is then accepted and delivered; afterwards one is accepted in under 0.3 s.unreadrises by 3.k1→ restart withk2,k1(old and new envelopes deliver, and so does ak2envelope with ak1-derived signing key, the interleaved-refresh state) → restart withk2(old gets200 retired_key, new delivers).rejected/connection_refused), and the receiver gets nothing.errorand the next delivery still arrives.python -m mcp_server_appwrite --transport http --events 1exits non-zero with theMCP_EVENTS_SEALING_KEYS is not setmessage. Short, all-zero, repeated, non-base64, id-less and duplicate-id keys stop startup withKeyringError.EventsDisabledFlow): with the flag unset,0orfalse,POST /appwrite/webhooks/{id}returns 404.Unit tests: none remain for the ingress or projection.
test_telemetry.py's two events-counter tests are removed because the flows above assert the same counters through the real OTLP export. The CallbackFailure refactor commit updates #133's remaining delivery unit tests to the shared enum.Review changes
326d338:
200and the drop is counted asbacklog_full. Documented under "Delivery backlog" indocs/events.md.2a428f7:
unread.Also in 326d338:
edd3eab (rebase onto #132's JWE envelope):
envelope.key_id) and opens withKeyring.open(envelope, Context(...)), binding this server as well as the project and webhook. Expiry now comes from the package'sExpiredSubscription(still200 dropped expired); theexpireddrop no longer carries the event label, because an expired envelope is not returned.Callback(one secret since feat: sign and deliver MCP events safely #133's 656ac20) the record's secret. The forged-deliveries flow gains an envelope sealed for another server and one with an unsupported header, and its malformed cases are JWE-shaped now.docs/events.mdgains an Envelope section: the profile, the record, context binding, key requirements (32 random bytes each) and the measured sizes.91b2fa9 (Hansi re-review after the rebase):
READS = 32bodies at once; see Flow step 2. The bounded-reads flow covers it and fails without the limiter.Verification
Run locally on Python 3.12.8, lockfile written with uv 0.11.22 (the CI version):
uv run --group dev ruff check src tests: passuv run --group dev black --check src tests: passuv run --group dev pyright: 0 errorsuv run python -m unittest discover -s tests/unit: 309 tests OKuv run --group e2e python -m unittest discover -s tests/e2e: 16 tests OK (about 26 s)docker build -t appwrite-mcp:jwe .: buildsLeft for PR 5
events/subscribeandevents/unsubscribe: authorization reads, verification handshake, webhook upsert withurl = Ingress.url(id),tls: true, a fixed non-emptyauthUsername(Appwrite only sends Basic auth when both user and password are set),authPassword = keyring.seal(...),secret = keyring.signing_key(id)and events= event.patterns(arguments), plus cleanup of expired webhooks. Dashboard panels for the two new counters go in thedashboardsrepo.