Skip to content

feat: receive Appwrite webhooks and forward MCP events - #134

Merged
ChiragAgg5k merged 11 commits into
feat/eventsfrom
feat/events-ingress
Oct 12, 2026
Merged

ChiragAgg5k merged 11 commits into
feat/eventsfrom
feat/events-ingress

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Stack

feat/events ← #131 ← #132 ← #133 ← #134 ← #135 (events/subscribe / events/unsubscribe)

Merges into feat/events; feat/events → main lands 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 at POST /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/unsubscribe are still not served; they come in PR 5.

  • One failure enum. delivery.Reason duplicated errors.CallbackFailure value for value. delivery.py now uses CallbackFailure, and CallbackError.reason is typed with it, so subscribe can pass it straight to EventsError.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's payload_schema, so raw bodies never go out. It checks resource IDs against the sealed arguments, matches X-Appwrite-Webhook-Events against the subscription's patterns, applies the status filter, and normalizes timestamps.
  • events/ingress.py. The Starlette endpoint and the Ingress that owns the delivery task group, plus Ingress.from_env().
  • http_app.py. The route is mounted only when events_protocol.enabled("http"). ingress.run() joins session_manager.run() in the lifespan through an AsyncExitStack. build_app(ingress=None) takes an injected ingress for tests. With the flag on and no MCP_EVENTS_SEALING_KEYS, startup fails with KeyringError.
  • envelope.appwrite_hmac(url, key). The HMAC state behind appwrite_signature, so the ingress can hash the body while it streams in. appwrite_signature now uses it, and its PHP test vector still passes.
  • Telemetry. Two counters, mcp.events.ingress (outcome, reason, event) and mcp.events.deliveries (event, outcome, reason). This also removes the # --- … --- section-header comments from telemetry.py.

Flow

  1. Credentials. The Basic auth password is the envelope, a JWE from feat: seal MCP event subscriptions into an envelope #132's mcp-subscription+jwe profile. envelope.key_id reads the kid from its protected header. If there is no Basic auth, the password is not JWE Compact with a valid kid, or the id is not a subscription id, the response is 401 credentials.
  2. Signature. An envelope naming a key that is not in the ring is 200 retired_key. Otherwise the ingress derives keyring.signing_key(id, k) for every key k in the ring. It rebuilds the registered URL as {MCP_PUBLIC_URL}/appwrite/webhooks/{id} and ignores the inbound Host. It streams the body into HMAC-SHA1(url + body) under each key and keeps up to 2 MiB, then compares each result in constant time with X-Appwrite-Webhook-Signature. No match is 401 signature. The read is bounded because the route is public:
  • A request without a signature is refused before its body is read.
  • A body over 16 MiB (declared or streamed), or still arriving after 10 s, is not read on and gets 200 dropped unread.
  • A process reads at most 32 bodies at once (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.
  1. Envelope. 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 package EnvelopeError (tampered, not the profile, bound to another server, project or webhook) or a record that does not hash to the id is 401 envelope. The package's ExpiredSubscription is 200 dropped expired.
  2. Drop checks. Catalog lookup, body size, JSON object, event-header match, then projection (shape, resource IDs, status filter).
  3. Accept. eventId = "evt_" + sha256(canonical_json([X-Appwrite-Webhook-Delivery-Id, subscription id]))[:32], which stays the same across Appwrite retries. timestamp is 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 calls task_group.start_soon(dispatcher.deliver, …) and returns 200 {"status":"accepted","eventId":…} straight away. With no slot free, it answers 200 dropped backlog_full and 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-stateless and the Appwrite source):

Event Rule
Executions Function from resourceId; resourceType must be functions. Sync runs fire .create already terminal and are delivered. Async .create with waiting is dropped. Async .update with failed is delivered, even with no $createdAt (created_at: null).
Deployments Only ready / failed. Activation (PATCH /functions/:id/deployment) fires deployments.*.update with a function model, which has no resourceType; it is detected by shape and dropped. A duplicate fires with a deployment that is still waiting and is dropped.
Rows / files $databaseId / $tableId / bucketId, when present, must equal the sealed arguments
Users Only user_id and created_at; no email, phone, name, labels or prefs
Timestamps DB format without a zone (2026-10-09 09:38:26.634, UTC) or response-model format, normalized to 2026-10-09T09:38:26.634Z; missing or invalid gives null

Drop / ACK rules

Appwrite treats any >= 400 as 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 a 200.

Case Response Delivered
Valid, matching event 200 accepted yes
Subscription expired 200 dropped expired no
Envelope names a key no longer in the ring 200 dropped retired_key no
Sealed event name not in the catalog 200 dropped unknown_event no
X-Appwrite-Webhook-Events has none of the subscription's patterns 200 dropped event_mismatch no
Body names another function, table or bucket 200 dropped resource_mismatch no
Body is not the expected model (deployment activation, wrong resourceType, no $id) 200 dropped shape no
Status filter rejects (waiting, ready vs a failed filter) 200 dropped status no
Body is not a JSON object 200 dropped malformed no
Body over 2 MiB, signature valid 200 dropped too_large no
Body over 16 MiB or slower than 10 s (signature never checked) 200 dropped unread no
Delivery backlog full (process or subscription) 200 dropped backlog_full no
No Basic auth, or the password is not an envelope 401 credentials no
Signature missing or wrong 401 signature no
Envelope tampered with, or for another webhook or project 401 envelope no

Why 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 with k2. Their writes can interleave (old PUT, new PUT, new PATCH /secret, old PATCH /secret) and leave a k2 envelope next to a k1-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_key instead of 401. 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_URL breaks 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 in tests/e2e/test_events_ingress.py. The recorded-shape Appwrite fixtures moved to tests/e2e/fixtures/appwrite/, unchanged.

Harness additions (tests/e2e/support.py):

  • Appwrite plays the webhooks worker. It sends the X-Appwrite-Webhook-* headers, uses Basic auth only when user and password are both set, and signs X-Appwrite-Webhook-Signature = base64(HMAC-SHA1(url . body, secret)) over the webhook's configured URL. X-Appwrite-Webhook-Events is generated like Event::generateEvents, and the delivery id is md5(event:webhook). Requests are addressed to the server's bound socket while MCP_PUBLIC_URL is https://mcp.e2e.test, like a load balancer in front of the pod.
  • Receiver plays ChatGPT's endpoint: a real HTTPS server on localhost with a self-signed certificate. It checks every request with the official standardwebhooks library (new test-only dependency in the e2e group), echoes verification challenges, records SNI, and replays scripted replies (status, delay, redirect).
  • Collector is an OTLP/HTTP endpoint. Every server exports its real metrics to it, so counters are asserted on what the server emits. force_flush makes 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. Until events/subscribe exists, each test writes the webhook exactly as PR 5 will (sealed envelope as authPassword, derived secret, the event's patterns).

E2E flows:

  • Every catalog event reaches the subscriber: for all 6 events, a signed Appwrite delivery gets 200 accepted and exactly one POST at the receiver. The POST is standardwebhooks-verified, webhook-id = eventId = the ingress's eventId, X-MCP-Subscription-Id is set, the timestamp is fresh, and SNI / Host are localhost. data validates against the payloadSchema the same server publishes on events/list and 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_at only) are checked exactly.
  • Appwrite quirks: a sync execution .create (already failed) is delivered. An async .create while waiting, and a completed execution, are dropped (status). An async .update (failed) is delivered with DB-format timestamps normalized and created_at: null. A wrong resourceType is 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). The status argument narrows delivery: with status=failed, a ready deployment is dropped and a failed one is delivered. Bad mimeType / size types become null, a +02:00 time converts to UTC, a body without $id is dropped (shape), and an unparseable time becomes null with the event stamped on receipt.
  • Stable event ids: replaying one Appwrite delivery id yields the same eventId twice at the receiver. A new delivery, or the same delivery to another subscription, yields a new one.
  • Acknowledged drops, nothing delivered: expired, event mismatch (other table, empty header), resource mismatch (row table, execution function), malformed (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, and mcp.events.deliveries{outcome=too_large} by one.
  • Forged deliveries get 401 and nothing is delivered: wrong or empty signature; signed over the inbound URL instead of the registered one; tampered envelope; envelope for another project; envelope copied from another webhook; an envelope sealed with the server's key for this webhook and project but by another deployment (another MCP_PUBLIC_URL); a JWE naming a ring key with an unsupported enc; an envelope sealed with the server's key for this webhook and project but holding another subscription's contents (all 401 envelope); no Basic auth, a Bearer header, bad base64, an empty password, the old v1.k1.… format, too many parts, a non-empty encrypted-key segment, a header that is not base64url JSON, a header without kid, or an invalid kid (all 401 credentials); a path id that is not a subscription id, including Appwrite-valid sub_zzzz… / sub_AAAA…. mcp.events.ingress{outcome=rejected} rises by 23. GET returns 405. A spoofed Host / X-Forwarded-Host is ignored and the delivery succeeds.
  • Retries and give-up: receiver 500 → timeout → 302 → 200 gives four attempts with the same webhook-id and newer timestamps, and the redirect target is never requested. 410 and 413 get one attempt each. 503 is abandoned after 4. An untrusted certificate gets no request. Counters: delivered +1, rejected/http_4xx +2, abandoned/http_5xx +1, abandoned/tls_error +1.
  • Burst: 8 events to one host all arrive, with at most 4 in flight. With 0.6 s replies and a 1 s timeout, the 4 queued behind the first four time out (the deadline includes the slot wait, feat: sign and deliver MCP events safely #133) and arrive on retry: 12 attempts.
  • Backlog: a server with backlogs of 3 per process and 2 per subscription accepts two deliveries for one subscription and answers the third with 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_full rises by 2.
  • Bounded reads: on a server with a 0.5 s read timeout and one read slot, three bodies are each dropped as 200 unread with 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. unread rises by 3.
  • Sealing key rotation across restarts: k1 → restart with k2,k1 (old and new envelopes deliver, and so does a k2 envelope with a k1-derived signing key, the interleaved-refresh state) → restart with k2 (old gets 200 retired_key, new delivers).
  • Production egress: a server built purely from the environment refuses a loopback callback (rejected/connection_refused), and the receiver gets nothing.
  • Fault isolation: with a dispatcher that raises once, the counter records error and the next delivery still arrives.
  • Startup: python -m mcp_server_appwrite --transport http --events 1 exits non-zero with the MCP_EVENTS_SEALING_KEYS is not set message. Short, all-zero, repeated, non-base64, id-less and duplicate-id keys stop startup with KeyringError.
  • Flag off (added to feat: advertise MCP events and list the event catalog #131's EventsDisabledFlow): with the flag unset, 0 or false, 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:

  • Hansi, unbounded delivery backlog: deliveries in flight are now capped at 1024 per process and 64 per subscription. When full, Appwrite still gets 200 and the drop is counted as backlog_full. Documented under "Delivery backlog" in docs/events.md.
  • Code review, interleaved refreshes across a key rotation: the signature is now verified against every ring key (see above).

2a428f7:

  • Hansi re-review, unbounded body reads: a missing signature is refused before the body is read, and bodies over 16 MiB or slower than 10 s are dropped as unread.

Also in 326d338:

  • Coverage moved up from the stack's earlier unit tests: status-filter cases (completed execution, building deployment, requested status delivered); truncated, non-base64, invalid key id and wrong-binding envelopes; foreign ids; the egress deadline including the slot wait.

edd3eab (rebase onto #132's JWE envelope):

  • The ingress reads the key id from the JWE header (envelope.key_id) and opens with Keyring.open(envelope, Context(...)), binding this server as well as the project and webhook. Expiry now comes from the package's ExpiredSubscription (still 200 dropped expired); the expired drop no longer carries the event label, because an expired envelope is not returned.
  • A subscription holds one delivery secret, so the "rotated client secrets" flow is removed, and 9339d89 hands 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.md gains 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):

  • Bound concurrent unauthenticated body reads: a process reads at most READS = 32 bodies 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: pass
  • uv run --group dev black --check src tests: pass
  • uv run --group dev pyright: 0 errors
  • uv run python -m unittest discover -s tests/unit: 309 tests OK
  • uv run --group e2e python -m unittest discover -s tests/e2e: 16 tests OK (about 26 s)
  • docker build -t appwrite-mcp:jwe .: builds

Left for PR 5

events/subscribe and events/unsubscribe: authorization reads, verification handshake, webhook upsert with url = Ingress.url(id), tls: true, a fixed non-empty authUsername (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 the dashboards repo.

Related: #127

@hansi-codes

hansi-codes Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

The documentation clarification matches the ingress flow, and this incremental change introduces no actionable issue.

Adds a feature-flagged Appwrite webhook ingress that authenticates and opens sealed subscriptions, projects event-specific payloads, and forwards signed MCP events through the callback delivery path. It integrates ingress lifecycle, key configuration, bounded reads and delivery backlogs, and telemetry with the hosted server, with documentation and end-to-end coverage for filtering, retries, rotation, and failure handling.

Latest changes: The documentation now clarifies that expired subscriptions are acknowledged before webhook signature verification.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 21
File Change
.env.example, AGENTS.md, docs/events.md, docs/flags.md Document MCP Events configuration, architecture, ingress behavior, and the expired-subscription acknowledgement order.
pyproject.toml, uv.lock Add and lock the end-to-end Standard Webhooks test dependency.
src/mcp_server_appwrite/events/delivery.py, src/mcp_server_appwrite/events/envelope.py Use the shared callback failure type and provide envelope key and streaming HMAC helpers.
src/mcp_server_appwrite/events/ingress.py, src/mcp_server_appwrite/events/projection.py Implement bounded webhook ingress, envelope validation, event filtering, and catalog-specific payload projection.
src/mcp_server_appwrite/http_app.py, src/mcp_server_appwrite/telemetry.py Mount and manage ingress in the HTTP app lifecycle and add ingress and delivery counters.
tests/e2e/fixtures/appwrite/*.json, tests/e2e/support.py, tests/e2e/test_events_ingress.py, tests/e2e/test_events_protocol.py Add recorded webhook fixtures and end-to-end coverage for ingress, delivery, limits, rotation, and feature-flag behavior.
tests/unit/test_events_delivery.py Update delivery unit tests to use the shared callback failure enum.
src/mcp_server_appwrite/events/projection.py Project catalog event payloads and enforce event, resource, shape, and status filters.
src/mcp_server_appwrite/events/ingress.py Add the bounded webhook ingress and lifecycle-managed callback delivery, including acknowledgement of expired envelopes before signature verification.
src/mcp_server_appwrite/http_app.py Mount and run the ingress when MCP Events is enabled.
src/mcp_server_appwrite/telemetry.py Add ingress and event-delivery counters.
tests/e2e/support.py Add Appwrite webhook, HTTPS receiver, and OTLP collector test harnesses.
tests/e2e/test_events_ingress.py Cover ingress authentication, projection, delivery behavior, limits, rotation, and expired orphan acknowledgements.
tests/e2e/test_events_protocol.py Cover ingress route behavior with the events feature flag enabled and disabled.
.env.example Documents the sealing-key requirement when MCP Events is enabled.
AGENTS.md Adds the events package and responsibilities to the architecture table.
docs/events.md Documents ingress, envelope binding, projection, limits, drop behavior, and metrics.
docs/flags.md Updates MCP Events setup and documents the webhook route.
pyproject.toml Adds the Standard Webhooks library to the e2e test dependencies.
src/mcp_server_appwrite/events/delivery.py Uses the shared callback failure enum in delivery results and verification errors.
src/mcp_server_appwrite/events/envelope.py Factors Appwrite HMAC initialization into a streaming helper.

Reviewed the commits since dcfd4c2 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread src/mcp_server_appwrite/events/ingress.py
hansi-codes[bot]
hansi-codes Bot previously requested changes Oct 11, 2026

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread src/mcp_server_appwrite/events/ingress.py Outdated

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@hansi-codes
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.
hansi-codes[bot]
hansi-codes Bot previously requested changes Oct 11, 2026

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread src/mcp_server_appwrite/events/ingress.py Outdated
@hansi-codes
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.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

After a key rotation an orphaned webhook can pair an expired envelope from the
new key with a signing secret from the retired one. Checking the signature
first answered 401, so Appwrite paused the webhook and emailed the owner.
Opening the envelope first authenticates it and drops the expired lease with
200.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@ChiragAgg5k
ChiragAgg5k changed the base branch from feat/events-delivery to feat/events October 12, 2026 08:24
@ChiragAgg5k
ChiragAgg5k merged commit 9aeabda into feat/events Oct 12, 2026
6 checks passed
@ChiragAgg5k ChiragAgg5k mentioned this pull request Oct 12, 2026
2 tasks done
ChiragAgg5k added a commit that referenced this pull request Oct 12, 2026
* feat: add the MCP events catalog and error codes

Six v1 events, each declared as data: arguments, Appwrite pattern templates, an IDs-only payload schema and the status filter the ingress will apply. Arguments are checked against a dot- and wildcard-free Appwrite ID pattern before they are spliced into an Appwrite event pattern, so a subscription can never widen past the named resource. Error codes sit behind constants because SEP-3415 renumbers them. The MCP_EVENTS flag gates the feature (off by default).

* feat: advertise MCP events and serve events/list

The SDK drops unknown capability keys (python-sdk#3640), so a Server.middleware adds capabilities.events for ChatGPT and the SEP-3415 extension entry to the discover and initialize results. Only on the HTTP transport with MCP_EVENTS on. Tests assert on raw HTTP through the hosted app because the SDK client models drop the key too.

* docs: document MCP events and the events flag

* test: cover events discovery end to end against the hosted server

Boot the real Starlette app under uvicorn and drive it over HTTP like
ChatGPT, so the wire is what is asserted. Unit tests the e2e flow now
covers are removed; only what HTTP cannot reach before subscribe stays.

* ci: run the e2e suite on every pull request

It needs no credentials, so unlike integration it also runs for forks.

* feat: seal MCP event subscriptions into an envelope

Each MCP Events subscription is stored only in its Appwrite webhook. The
envelope seals the delivery state into authPassword with AES-256-GCM, bound
to the subscription id and project, and the webhook signing key is derived
so the ingress can verify deliveries without any storage.

* test: keep only envelope tests that e2e cannot reach

Round trips, tampering, binding, rotation and expiry are covered through
the ingress e2e flows in the layer above. What stays is the PHP-computed
Appwrite signature vector, subscription id determinism and charset until
subscribe has an e2e flow, and the size budget for appwrite/appwrite#14293.

* fix: accept only generated subscription ids

valid_subscription_id accepted any 32-char Appwrite-safe suffix, so ids like
sub_zzzz... passed as ours although subscription_id() only ever emits 32
lowercase hex characters. The ingress route and managed-webhook recognition
use it to tell our ids from foreign ones, so they now reject anything else.

* refactor: seal subscriptions with the mcp-event-subscriptions JWE profile

The custom v1.<kid>.<AES-GCM> envelope becomes a Subscription Record v1
sealed with the package's mcp-subscription+jwe profile, bound to this
server, the project and the webhook id. The adapter keeps what the profile
leaves to us: subscription ids, the principal digest, the derived Appwrite
signing key and the authPassword size budget.

* fix: pin the clock for the conformance vector and keep secrets out of repr

The published vector's lease ends in 2030, so Keyring.record and open take
an optional clock that the vector test pins, as the package's own tests do.
A Subscription's whsec_ secret is no longer part of its repr.

* test: check a subscription's repr hides its secret

* feat: sign and deliver MCP events safely

Callback URLs are client-chosen, so every webhook POST is an
attacker-steerable request. Add an SSRF-safe egress path that pins the
connection to a checked global address while keeping SNI and Host, and a
Standard Webhooks dispatcher for verification handshakes and bounded,
re-signed event delivery retries.

* test: keep only delivery and egress tests that e2e cannot reach

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.

* fix: confine test-only http to loopback and bound queued egress

allow_loopback widened the allowed schemes to http for every host, so an
embedder enabling it could send signed event data unencrypted to a public
callback. Plain http now needs a loopback host (localhost or a loopback IP)
whose every resolved address is loopback.

The per-host slot was acquired before the request deadline started, so a
request queued behind slow ones to the same host could wait without bound and
go out with a signature timestamped long before it was sent. One deadline now
covers the slot wait and the request, and Egress.post takes a headers
factory that is called only once the slot is held, right before sending.

* refactor: deliver with one secret and keep it out of repr

A subscription record carries one delivery.secret, so a Callback holds one
secret and signs once; the multi-secret path had nothing left to feed it.
The secret is no longer part of a Callback's repr.

* refactor: report delivery failures with errors.CallbackFailure

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.

* feat: receive Appwrite webhooks and forward MCP events

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.

* docs: describe the events ingress and the always-2xx rule

* test: cover the events ingress end to end

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.

* fix: bound the delivery backlog and verify against every ring key

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.

* fix: bound how much of a webhook body the ingress reads

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.

* refactor: open JWE subscription envelopes in the ingress

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.

* fix: bound how many webhook bodies the ingress reads at once

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.

* feat: serve events/subscribe and events/unsubscribe

Each subscription is one managed Appwrite webhook in the subscriber's project,
written with their own OAuth token, so the hosted server stays stateless.
Subscribe validates, authorizes with a resource read, cleans up the caller's
expired webhooks, verifies the callback and upserts the webhook; unsubscribe
deletes it by the principal-bound id.

* refactor: hand the ingress callback its one delivery secret

* test: cover subscribe and unsubscribe end to end

An Appwrite REST emulator joins the e2e harness, and the webhooks worker now
fires the webhooks the server actually stored, closing the loop from subscribe
to signed delivery to unsubscribe. The unit tests that only existed because
nothing called the catalog, whsec_ validation, the handshake or subscription
ids over HTTP are gone.

* docs: describe subscribe, unsubscribe and the managed webhook

Covers the TTL policy, the webhook label used for cleanup, Free-plan limits,
required OAuth scopes and secret rotation, and keeps the events flag framed as
a testing flag.

* fix: forbid unsubscribe from missing projects and guard cleanup races

events/unsubscribe for a project that does not exist reached the webhook
error fallback as -32603. A 404 project_not_found now maps to -32012 like
subscribe, so project ids cannot be probed; a missing webhook stays an
idempotent {}.

Expired-webhook cleanup decided from the earlier listing and deleted
unconditionally, so a concurrent refresh of that subscription could have its
now-live webhook deleted. Appwrite has no conditional delete, so cleanup now
only considers webhooks expired for over 10 minutes and re-reads each one
right before deleting it, skipping it when its name or $updatedAt changed.

The e2e flows add the Appwrite emulator's interleaving hook and use it for a
refresh landing mid-cleanup and for two replicas refreshing one subscription
across a key rotation, and check that the server refuses exactly what the
published input schemas reject.

* fix: check write scope before verifying and keep refreshed webhooks

A token that could list webhooks but not write them passed the listing and
got the callback a signed verification POST before the write failed. The
token's scope claim is now checked first: without project:webhooks.write,
project:all or all it is -32012 before Appwrite or the callback is reached.

A refresh whose secret update failed deleted the webhook. The previous signing
key was derived from a key still in the ring, and the ingress accepts every
ring key, so the webhook keeps delivering; the error is still reported so
the client retries.

Rotation is documented as three deploys (add the key behind the active one,
then move it first, then drop the old one), so no replica ever receives a
delivery sealed with a key it does not hold.

* refactor: seal subscribe requests as JWE subscription records

events/subscribe seals one delivery secret and the granted lease
(expiresAt, refreshBefore) into the record, bound to this server through
Ingress.server, and answers -32602 for a delivery the record refuses. The
subscribe flow now opens the stored envelope and checks its key id, callback,
secret and lease.

* refactor: verify subscribe callbacks with their one secret

* fix: write the signing key before the envelope on refresh

A failed second write left a new envelope with a signing key derived from the
previous sealing key, which could be retired while that envelope was still
live. Writing the signing key first bounds a stale pairing by the previous
envelope's own expiry.

* docs: retire a sealing key a full TTL after the rotation reaches every replica

Concurrent refreshes during step 2 can pair a new-key envelope with an old-key
signing secret. Only step-1 replicas write that secret, so counting the TTL from
the end of step 2 guarantees such envelopes expire before the old key leaves the
ring. The e2e now produces that pairing and delivers through both replicas.

* fix: acknowledge expired subscriptions before checking the signature

After a key rotation an orphaned webhook can pair an expired envelope from the
new key with a signing secret from the retired one. Checking the signature
first answered 401, so Appwrite paused the webhook and emailed the owner.
Opening the envelope first authenticates it and drops the expired lease with
200.

* docs: note that expiry is checked before the signature

* docs: name the expired drop for orphans after a key is retired
@ChiragAgg5k ChiragAgg5k self-assigned this Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant