Skip to content

feat: serve events/subscribe and events/unsubscribe - #135

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

ChiragAgg5k merged 12 commits into
feat/eventsfrom
feat/events-subscribe

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Stack

feat/events ← #131 ← #132 ← #133 ← #134 ← this PR

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

The top layer of MCP Events (#127): events/subscribe and events/unsubscribe, registered by protocol.register(server, …) only with the MCP_EVENTS flag on and the HTTP transport. The server stays stateless: each subscription is one managed Appwrite webhook in the subscriber's project, written with the subscriber's own OAuth token, and the sealed envelope lives only in its authPassword.

  • events/subscriptions.py: Subscriptions.subscribe / unsubscribe, the TTL Grant, and the mapping from Appwrite errors to events codes.
  • events/webhooks.py: what a managed webhook looks like (Label name, fixed authUsername, derived secret), how it is recognized, and the SDK calls (appwrite_console Webhooks service).
  • events/catalog.py: each event declares its authorization read (Access: path, scope, how errors name the resource).
  • events/protocol.py: registers both methods, with tools/call-style telemetry and Sentry reporting.
  • events/ingress.py: exposes its keyring, egress, dispatcher and server (the context envelopes are bound to) so subscribe shares them.
  • server.py / http_app.py: build_mcp_server(..., ingress=), so subscribe writes webhooks pointing at the same ingress the app mounts.
  • telemetry.py: mcp.events.subscriptions (operation, outcome, event, reason).

Subscribe flow

  1. Validate. catalog.lookup(name) (-32011, kind: "event"), event.validate(arguments) (-32602), delivery.mode must be webhook (-32014, absent means webhook), callback must be https without credentials and pass Egress.check (private, loopback, metadata → -32602), delivery.decode_secret (-32602), ttlMs / maxAgeMs shape (-32602).
  2. Principal from the token: Principal(iss, sub, client_id).digest. A token without sub is -32012; no token never reaches JSON-RPC (HTTP 401). A token whose scope claim has none of project:webhooks.write, project:all or all is -32012 here, before Appwrite or the callback is contacted. A token with no scope claim is left for Appwrite to refuse.
  3. Grant and seal. The subscription becomes a Subscription Record v1 (one delivery.secret, lease.expiresAt and lease.refreshBefore from the grant) sealed with feat: seal MCP event subscriptions into an envelope #132's mcp-subscription+jwe adapter for this server (Ingress.server, i.e. MCP_PUBLIC_URL), the project and the subscription id. EnvelopeTooLarge → -32602; InvalidSubscription (a delivery the record refuses: a URL with a fragment, a secret whose base64 is not canonical) → -32602. Sealing happens before anything leaves the server.
  4. Authorize with the caller's token via resolve_client(project_id): GET /functions/{id}, /sites/{id}, /tablesdb/{db}/tables/{table}, /storage/buckets/{id}, or GET /users with limit(1) (least privilege that proves users.read).
  5. List webhooks and clean up the caller's managed webhooks that expired more than 10 minutes ago and are unchanged when read again. Listing first means a missing project:webhooks.* scope fails before the callback gets a POST.
  6. Verify the callback (handshake, every time).
  7. Upsert: create ($id = subscription id, url = Ingress.url(id), tls: true, enabled: true, events = event.patterns(arguments), authUsername = mcp-events, authPassword = envelope, secret = keyring.signing_key(id)), or on refresh PUT then PATCH /secret. A 409 race falls through to the refresh path.

Result: {id, refreshBefore, cursor: null, truncated: false} plus the SDK's resultType: "complete".

events/unsubscribe validates name and arguments, recomputes the id from the principal and delivery.url, and deletes the webhook only if it is managed. A missing webhook still returns {}. A missing project is -32012, as on subscribe. The id includes the principal, so a caller can only reach their own subscriptions (tested with a second user).

Decisions

Verification is not cached; the handshake runs on every subscribe and refresh. There is no server-side store, and authPassword is write-only, so the server cannot tell whether a refresh carries the same callback and secret as the live webhook. Keeping a "verified" fact in the new envelope would not help either: it would have to be trusted from the request being verified. One signed POST per refresh (hourly by default, at most every 5 minutes) is cheap, keeps the spec's MUST trivially true, and re-proves the callback wants deliveries under the secret it is about to get. The spec's per-(principal, url) cache is a SHOULD-level optimization for servers with memory.

Expired cleanup reads expiry from the webhook name. The name is a label: MCP events | <event> | until <UTC expiry> | owner <first 8 chars of the principal digest>. It is the only place the expiry can be read back without opening the envelope. $updatedAt + max TTL was the alternative, but it would keep an expired 1 h subscription holding a Free-plan slot for up to 24 h. Cleanup deletes only webhooks whose id is a subscription id and whose name parses as a label, whose owner tag is the caller's, and whose expiry passed more than 10 minutes ago.

Appwrite has no conditional delete, so a late refresh of the same subscription could race the cleanup. Two guards prevent that. The grace period keeps any subscription whose client is still refreshing out of the candidates. Each candidate is also re-read right before its delete and kept if its name or $updatedAt changed since the listing. The remaining window is a refresh landing between that re-read and the delete, for a subscription left expired for over 10 minutes; its next refresh recreates the webhook. A tag collision between users could at worst delete an expired webhook whose deliveries the ingress already drops. The separator is ASCII on purpose: Appwrite echoes the name in the X-Appwrite-Webhook-Name header of every delivery (the first draft used ·, which the e2e caught as an invalid header).

NotFound vs Forbidden. Appwrite authenticates the caller against the project before it looks the resource up, so a 404 for the resource itself (function_not_found, table_not_found, database_not_found, …) means the caller is in the project and the resource is missing: NotFound with data.kind = "resource". 401 / 403 (not a member, missing scope) is Forbidden, naming the scope to grant when Appwrite says general_unauthorized_scope. 404 project_not_found is also Forbidden, so project ids cannot be probed.

Secret rotation replaces; it cannot dual-sign. The previous secret exists only in the old envelope, which Appwrite never returns, and keeping it anywhere else would put a secret outside the envelope, which #127 rules out. Deliveries already in flight were opened from the old envelope and keep signing with the old secret through their retries, which covers the spec's "in-flight deliveries verify under either" intent. New deliveries sign with the new secret only. Documented in docs/events.md.

TTL. Default 1 h, floor 5 min, cap 24 h; absent or null → 1 h (never refreshBefore: null). The envelope's lease.expiresAt is the end of the grant, and refreshBefore (returned to the client and sealed as lease.refreshBefore) is a tenth of the grant earlier (6 min for 1 h, 30 s at the floor), so refreshBefore ≤ granted expiry ≤ suggestion (except at the floor, which the spec allows).

Concurrent refreshes across a key rotation. Two replicas with different active keys can interleave a refresh: old PUT, new PUT, new PATCH /secret, old PATCH /secret. That leaves the new key's envelope with the old key's signing secret. The ingress (#134) verifies the signature against every key in the ring, so such a webhook keeps delivering. A separate non-rotating root for the secret would also work, but it adds a second secret to configure that could never itself be rotated.

No lost subscriptions on a partial refresh. Create is one call. On refresh, if the PATCH /secret fails after the PUT succeeded, the webhook keeps its new envelope and previous signing key, and the error (-32603) makes the client retry. That key was derived from a key still in the ring, and the ingress accepts every ring key (#134), so deliveries continue. The secret is always re-set rather than compared, because Appwrite's current GET and PUT no longer return it.

Key rotation is staged. Sealing with a key that some replica does not hold yet would make that replica drop deliveries as retired_key. Rotation is therefore three deploys: add the key behind the active one (k1,k2), move it first (k2,k1), then drop the old one after the 24 h cap. This is documented in docs/events.md.

Plan limit max. Cloud refuses with 403 additional_resource_not_allowed when total >= planMax, so data.max is the number of webhooks in the project after cleanup.

Error mapping

Situation Code data
Bad arguments (wildcards, dots, unknown keys, missing project_id), non-https / credentialed / fragment / private / unresolvable callback, bad or non-canonical whsec_, oversized envelope, bad ttlMs / maxAgeMs -32602
Unknown event -32011 {"kind": "event"}
Resource missing in a project the caller can access -32011 {"kind": "resource"}
No user behind the token; no access to the project or resource; missing read scope; missing project:webhooks.read / .write; nonexistent project; subscription id taken by a renamed webhook -32012
Plan webhook limit -32013 {"limit": "webhooks", "max": <n>}
delivery.mode not webhook -32014
Handshake failed -32015 {"reason": challenge_failed | timeout | connection_refused | tls_error | http_4xx | http_5xx}
Appwrite 5xx or anything unexpected -32603

Only -32603 goes to Sentry (tags: mcp.method, transport, client tags, event.name, appwrite.project_id). Every outcome is counted in mcp.messages.received, mcp.jsonrpc.errors and mcp.events.subscriptions.

Tests

Harness (tests/e2e/support.py). Cloud is a small Appwrite REST emulator the server reaches through APPWRITE_ENDPOINT: the console region lookup resolve_client makes, the five authorization reads, and /v1/webhooks (list with limit/offset, create, get, update, delete, PATCH /secret). It checks projects (404 project_not_found), membership (401 user_unauthorized) and scopes (401 general_unauthorized_scope) like Appwrite, keeps authPassword write-only and returns the secret only from create and the secret update (current Appwrite), enforces the Free plan limit (403 additional_resource_not_allowed), CustomId, the 128-char name and a 2048-char authPassword (what #14293 must allow), and takes injected failures. Appwrite.fire(cloud, project, event, body) delivers from the webhooks the server stored, with their real URL, Basic auth (authUsername + envelope) and HMAC secret, matching events the way Appwrite's worker does. The token stub now returns the claims the real verifier produces (iss, sub, client_id, project_id) for a few users.

Cloud.before(method, path, action) runs another client's work once, right before a matching request is handled. Flows use it to interleave a concurrent replica or refresh between two of the server's own calls.

Flows (tests/e2e/test_events_subscribe.py, 9 tests, ~13 s):

  • Full loop: subscribe → signed handshake at the receiver → webhook stored with the exact fields above (secret and callback appear nowhere but inside the envelope; the stored authPassword is a JWE with kid k1 that opens for this server, project and id, and carries the callback, the secret and a lease.refreshBefore equal to the returned refreshBefore) → Appwrite.fire → ingress → standardwebhooks-verified delivery. Refresh three times: same id, one webhook, refreshBefore for the floor (5 min), the cap (24 h) and null (1 h), with arguments reordered on one refresh; every refresh handshakes with a fresh challenge. Rotation: handshake and next delivery verify with the new secret only. Another user's unsubscribe for the same URL touches nothing. Unsubscribe → webhook gone → Appwrite fires nothing → no delivery; unsubscribing again is {}. Subscription counters: created +1, refreshed +4, removed +1, absent +2.
  • Interleaved refreshes across a key rotation: models step 2 rolling out. The server (old replica, ring k1,k2) refreshes a subscription, and just before its PATCH /secret a new replica (ring k2,k1) refreshes the same subscription in full. The stored webhook ends with a k2 envelope and a k1 signing key. An event fired through either replica still opens, verifies and arrives.
  • Every catalog event: subscribes (secrets of 24, 32 and 64 bytes), and the published inputSchema accepts each argument set the server accepts, reads its resource with the caller's token, stores the event's patterns, and delivers a fired event end to end; ids are distinct. Missing function, site, table, database or bucket → -32011 kind: resource. Non-member, missing functions.read (message names project:functions.read), nonexistent project, and a token without sub → -32012. Unsubscribe from a nonexistent project → -32012. No bearer → HTTP 401. None of these POST to the callback or store a webhook.
  • Invalid requests (-32602): every ID argument of every event with *, a*, a.b, fn1.executions, empty, leading -/_, a space, 37 chars, null and 7; deployment status of building, READY, *, null and 7; unknown, missing or non-object arguments. For each of these argument cases, the inputSchema from events/list rejects it too, so Event.validate and the published schema agree. http, ftp, file, host-less, unparseable, credentialed, fragment, private and metadata callbacks; missing, non-string, empty, unprefixed, uppercase-prefixed, spaced, non-base64, badly padded, non-canonical, 23-byte and 65-byte secrets; an oversized envelope (a 2000-char padded callback, over the 2048 budget with the JWE sizes); bad ttlMs / maxAgeMs. Unknown event → -32011 kind: event; poll / push → -32014. None of them reaches Appwrite or the callback.
  • Handshake failures (-32015): challenge_failed (endpoint cannot verify; and a 307 that is not followed), http_4xx, http_5xx, timeout, tls_error (untrusted certificate), connection_refused. No webhook stored, no raw response in the message.
  • Appwrite refusals:
    • No webhook scopes → -32012 naming both scopes, before any handshake (unsubscribe too).
    • A token whose scope claim lacks webhooks.write → -32012, with no Appwrite request and no POST to the callback.
    • Appwrite 503 on the authorization read, the listing or the create → -32603 with nothing stored.
    • 500 on PATCH /secret during a refresh → -32603, but the webhook stays and a fired event is still delivered.
    • Counters: mcp.jsonrpc.errors{-32603} +4, subscribe -32012 +2.
  • Free plan and cleanup: a Free project holding the user's own webhook and one of ours that expired an hour ago → the expired one is removed (cleanup counter +1) and the subscription fits; a second → -32013 {limit: "webhooks", max: 2} with an upgrade-to-Pro / unsubscribe message; unsubscribing frees the slot.
  • Never touches others, and cleanup races: the following all survive cleanup byte for byte: the user's webhooks, user webhooks that copy our label but not our id format (including an Appwrite-valid but non-hex sub_zzzz…), a sub_ webhook renamed by a person, another user's expired subscription, our own live one, and our own that expired within the 10-minute grace period. A long-expired subscription of ours that its client refreshes after cleanup listed the project, right before cleanup re-reads it, survives with its new expiry. Each of these fails without the fix. a renamed webhook holding the subscription's own id makes subscribe fail with -32012 and unsubscribe leave it alone.
  • Production egress: a server built from the environment refuses a loopback callback with -32602, with no Appwrite call and no POST.

tests/e2e/test_events_protocol.py: subscribe/unsubscribe are no longer expected to be -32601 with the flag on; with it off, all three methods are. tests/e2e/test_events_ingress.py: docstring now explains why those flows still hand-build webhooks (expired, retired-key, foreign-event, tampered and foreign-server states subscribe never produces).

Unit tests deleted because these flows now cover them:

  • tests/unit/test_events_catalog.py (whole file): argument validation for every ID argument, and NotFound for unknown events.
  • tests/unit/test_events_delivery.py: SecretTest (all whsec_ cases) and VerificationTest (echo, fresh challenges, mismatch, 4xx/5xx, redirect not followed, timeout, connection refused, forbidden destination).
  • tests/unit/test_events_envelope.py: SubscriptionIdTests (determinism, argument order, principal binding, Appwrite custom-id charset, signing key fits Text(256, 8)).
  • tests/unit/test_events_egress.py: ValidateUrlTest (callback URL shapes).

No remaining unit test covers the EventsError constructors: every code and data shape is asserted on the wire instead.

Unit tests kept, and why e2e cannot reach them:

  • test_events_envelope.py: the published mcp-subscription+jwe conformance vector opened through our adapter (from feat: seal MCP event subscriptions into an envelope #132), the PHP-computed Appwrite signature vector (external truth; the harness signs with the same Python formula) and the envelope size budget (deliberately extreme inputs, the contract for #14293).
  • test_events_egress.py: the address blocklist (most ranges are unroutable from a test machine), mixed DNS answers and rebinding (need a resolver that changes its answer), the connect-target spy, and the 16 KiB response cap (invisible on the wire).
  • test_events_delivery.py: the production retry schedule (30 s / 2 min / 8 min, too long to wait for).
  • test_events_protocol.py: stdio never serves events (stdio needs a live project to boot).

Review changes

0923baf:

  • Hansi, unsubscribe from a missing project: 404 project_not_found on the webhook calls now maps to -32012 instead of -32603. A missing webhook stays an idempotent {}.
  • Code review, cleanup racing a refresh: cleanup now applies the 10-minute grace period and re-reads each candidate before deleting it (see Decisions).
  • Code review, interleaved refreshes during a key rotation: fixed at the ingress in feat: receive Appwrite webhooks and forward MCP events #134, and covered here end to end with two replicas.
  • Hansi on feat: advertise MCP events and list the event catalog #131, catalog unit coverage: Event.validate and published-schema parity are asserted end to end in the invalid-request and every-event flows.

9169169 (Hansi re-review):

  • Write-scope check before the verification POST: the token's scope claim is checked first.
  • Keep the webhook after a failed PATCH /secret: the webhook is no longer deleted.
  • Stage the new key across replicas: documented as a three-deploy rotation, and covered by the interleaved-refresh flow firing through both replicas.

75b7f73, 281373e (rebase onto #132's JWE envelope):

  • events/subscribe seals one delivery.secret and the granted lease (expiresAt, refreshBefore) into the record, bound to this server through Ingress.server. subscriptions.timestamp moved to envelope.timestamp, which formats both the result's refreshBefore and the sealed lease.
  • A delivery the record refuses (InvalidSubscription) is -32602; the invalid-request flow gains a fragment URL and a non-canonical secret, both refused before Appwrite or the callback are contacted. The oversized-envelope case still gets -32602 EnvelopeTooLarge with the larger JWE sizes.
  • The full-loop flow opens the stored envelope instead of checking for a v1.k1. prefix; the interleaved-rotation flow checks the stored kid is k2.
  • The verification handshake hands Callback its one secret (feat: sign and deliver MCP events safely #133's 656ac20). The delivery unit file keeps CallbackTest (secret not in repr) next to the retry schedule.

Hansi on 41c6731 (cleanup can still delete a refresh that lands between the re-read and the delete): this is the documented residual window; Appwrite has no conditional delete, so it cannot be closed from this side. Answered inline.

Verification

Run locally on Python 3.12.8 with uv 0.11.22 lockfile (the CI versions):

  • 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: 286 tests OK
  • uv run --group e2e python -m unittest discover -s tests/e2e: 25 tests OK (about 36 s; the dual-secret ingress flow went away with feat: receive Appwrite webhooks and forward MCP events #134's rebase)
  • docker build -t appwrite-mcp:jwe .: builds

Not yet

A live end-to-end run against Cloud staging (real OAuth tokens, real Appwrite webhooks and plan limits) follows appwrite/appwrite#14293, which must let authPassword hold up to 2048 characters (column and Text(256) validator). Until then the emulator above plays Appwrite.

Related: #127

@hansi-codes

hansi-codes Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🔵 Tier A · Mergeable after minor fixes

Limited by an open minor finding: A concurrent refresh can still be deleted after the re-read

Adds HTTP-only MCP Events subscribe and unsubscribe methods backed by managed Appwrite webhooks, with encrypted subscription envelopes, callback verification, authorization, TTL handling, cleanup, telemetry, and HTTP ingress wiring. The repository also gains subscription lifecycle documentation and end-to-end coverage for successful flows, failures, cleanup, and key rotation.

Latest changes: The newest commit clarifies that a webhook with a retired-key-derived signing secret is acknowledged as expired when its lease has elapsed, because ingress checks expiry before signature verification.

Verdict New comments Fixed Still open
💬 Commented 0 0 1

Note

Not approving while a bug finding is open: A concurrent refresh can still be deleted after the re-read

Fix with agent prompt
### Issue 1
src/mcp_server_appwrite/events/subscriptions.py:459-460
**A concurrent refresh can still be deleted after the re-read**

If another subscribe is cleaning an expired webhook while its owner refreshes it, a refresh that lands after this `GET` but before `DELETE` is still deleted. The refresh can return success even though its webhook is gone, so events are dropped until a later scheduled refresh.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 23
File Change
AGENTS.md Adds the events subscription modules to the architecture map.
README.md Updates the documented MCP Events feature status.
docs/development.md Documents staged key rotation and retirement timing.
docs/events.md Documents subscription behavior, webhook lifecycle, errors, and key rotation, including the latest clarification about expired subscriptions.
docs/flags.md Clarifies that subscription methods require the events flag and HTTP transport.
src/mcp_server_appwrite/events/catalog.py Adds event-specific resource authorization metadata.
src/mcp_server_appwrite/events/envelope.py Defines encrypted subscription records, keyring operations, and envelope validation.
src/mcp_server_appwrite/events/errors.py Adds subscription error codes and expected-error classification.
src/mcp_server_appwrite/events/ingress.py Exposes ingress components and handles Appwrite webhook deliveries for subscriptions.
src/mcp_server_appwrite/events/protocol.py Registers subscribe and unsubscribe methods with telemetry and error reporting.
src/mcp_server_appwrite/events/subscriptions.py Implements subscription validation, authorization, grants, cleanup, webhook writes, and error mapping.
src/mcp_server_appwrite/events/webhooks.py Defines managed webhook labels, recognition, and Appwrite SDK operations.
src/mcp_server_appwrite/http_app.py Builds the MCP server with the same ingress mounted by the HTTP application.
src/mcp_server_appwrite/server.py Passes ingress and request identity into events protocol registration.
src/mcp_server_appwrite/telemetry.py Adds subscription-operation metrics.
tests/e2e/support.py Adds an Appwrite REST emulator and webhook delivery helpers for subscription flows.
tests/e2e/test_events_ingress.py Updates ingress test setup and explains hand-built webhook test states.
tests/e2e/test_events_protocol.py Checks event method availability with the flag enabled and disabled.
tests/e2e/test_events_subscribe.py Covers subscription lifecycle, authorization, cleanup, failure cases, and interleaved key-rotation writes.
tests/unit/test_events_catalog.py Removes catalog validation tests now exercised through end-to-end flows.
tests/unit/test_events_delivery.py Retains callback and retry-specific unit coverage.
tests/unit/test_events_egress.py Retains unit coverage for egress protections not observable in end-to-end tests.
tests/unit/test_events_envelope.py Retains conformance, signature, and envelope-size unit tests.
⏳ Still open from earlier reviews · 1

Reviewed the commits since 205840a · 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/subscriptions.py

@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 force-pushed the feat/events-subscribe branch from f176565 to 0923baf Compare October 11, 2026 20:25

@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/subscriptions.py
Comment thread src/mcp_server_appwrite/events/subscriptions.py Outdated
Comment thread src/mcp_server_appwrite/events/subscriptions.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 A · See the inline comments. Summary

Comment thread src/mcp_server_appwrite/events/subscriptions.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 B · See the inline comments. Summary

Comment on lines +459 to +460
try:
webhooks.delete(webhook.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A concurrent refresh can still be deleted after the re-read

If another subscribe is cleaning an expired webhook while its owner refreshes it, a refresh that lands after this GET but before DELETE is still deleted. The refresh can return success even though its webhook is gone, so events are dropped until a later scheduled refresh.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcp_server_appwrite/events/subscriptions.py
Line: 459-460

Comment:
**A concurrent refresh can still be deleted after the re-read**

If another subscribe is cleaning an expired webhook while its owner refreshes it, a refresh that lands after this `GET` but before `DELETE` is still deleted. The refresh can return success even though its webhook is gone, so events are dropped until a later scheduled refresh.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · concurrency · Reply if this doesn't apply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the residual window already documented in _clean and under "Expired cleanup" in Decisions, and it cannot be closed from this side: Appwrite has no conditional delete (no If-Match / $updatedAt precondition on DELETE /webhooks/{id}), so any read-then-delete leaves a gap between the read and the delete. The mcp-event-subscriptions Appwrite profile lists the same constraint ("use conditional deletion or equivalent coordination").

What bounds it today: only webhooks expired for more than 10 minutes are candidates, so the subscription's own client already let it lapse well past refreshBefore; each candidate is re-read right before the delete and kept if its name or $updatedAt changed; and the client's next refresh recreates the webhook (create path), so the effect is a gap in deliveries for a subscription that was already lapsed, never a permanent loss. Closing the window fully needs a conditional delete in Appwrite; I would rather track that upstream than add a second best-effort read that only narrows it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The residual race is still possible: cleanup can read the old webhook, the owner’s refresh can update and successfully return, and cleanup can then delete that same ID. The re-read and subsequent unconditional delete are separate calls (cleanup); a refresh writes the webhook before returning its new refreshBefore (refresh flow, write). The grace period means the old lease had expired, but it does not prevent an in-flight refresh. That refresh can be lost until the next scheduled refresh, potentially nearly 24 hours later (TTL cap).

The lack of a conditional-delete API explains why this is hard to close here, but it doesn’t rule out the failure. Keeping the finding open; it may require upstream support for an atomic delete or equivalent coordination.

@ChiragAgg5k
ChiragAgg5k force-pushed the feat/events-subscribe branch from 8be3201 to 41c6731 Compare October 11, 2026 21:09
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.
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.
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.
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.
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.
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.
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.

@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/subscriptions.py
…y 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.
@ChiragAgg5k
ChiragAgg5k changed the base branch from feat/events-ingress to feat/events October 12, 2026 08:24
@ChiragAgg5k
ChiragAgg5k merged commit 4d0a54c into feat/events Oct 12, 2026
6 checks passed
@ChiragAgg5k ChiragAgg5k mentioned this pull request Oct 12, 2026
2 tasks done
@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