Repository navigation
feat: receive Appwrite webhooks and forward MCP events - #134
Conversation
🟢 Tier S · Ready to merge
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.
📂 Walkthrough · 21
Reviewed the commits since |
18e3823 to
7c03f56
Compare
7c03f56 to
326d338
Compare
8cc957c to
2d9cf2e
Compare
The findings that requested changes are resolved. See the summary comment for what is still open.
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.
2d9cf2e to
147d12d
Compare
2a428f7 to
6d46b33
Compare
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.
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.
147d12d to
656ac20
Compare
e389a85 to
9339d89
Compare
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.
* 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
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.Related: #127