Skip to content

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

Open
ChiragAgg5k wants to merge 3 commits into
feat/events-ingressfrom
feat/events-subscribe
Open

ChiragAgg5k wants to merge 3 commits into
feat/events-ingressfrom
feat/events-subscribe

Conversation

@ChiragAgg5k

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 and dispatcher 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).
  3. Grant and seal. EnvelopeTooLarge → -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 expired managed webhooks. 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. Missing → still {}. 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 has passed. 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 expires at the end of the grant, and 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).

No partial state. Create is one call. On refresh, if the PATCH /secret fails after the PUT succeeded, the webhook is deleted rather than left with an envelope and signing key that may disagree (Appwrite's current GET/PUT no longer return the secret, so it is always re-set rather than compared).

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 / private / unresolvable callback, bad 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.

Flows (tests/e2e/test_events_subscribe.py, 8 tests, ~10 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) → 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.
  • Every catalog event: subscribes (secrets of 24, 32 and 64 bytes), 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. 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 and 37 chars; an invalid deployment status; unknown or missing arguments; http, ftp, file, host-less, unparseable, credentialed, private and metadata callbacks; missing, non-string, empty, unprefixed, uppercase-prefixed, spaced, non-base64, badly padded, 23-byte and 65-byte secrets; an oversized envelope; 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). Appwrite 503 on the authorization read, the listing and the create → -32603 with nothing stored. 500 on PATCH /secret during a refresh → -32603 and the webhook is deleted. Counters: mcp.jsonrpc.errors{-32603} +4.
  • Free plan and cleanup: a Free project holding the user's own webhook and one of ours that expired → 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: the user's webhooks, a user webhook that copies our label but not our id format, a sub_ webhook renamed by a person, another user's expired subscription and our own live one all survive cleanup byte for byte; 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, two-secret and tampered 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 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).

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: 281 tests OK
  • uv run --group e2e python -m unittest discover -s tests/e2e: 23 tests OK (about 30 s), run twice
  • docker build -t appwrite-mcp:subscribe .: 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.

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.
@hansi-codes

hansi-codes Bot commented Oct 9, 2026

Copy link
Copy Markdown

🔵 Tier A · Mergeable after minor fixes

One minor error-mapping issue remains for unsubscribe requests against a nonexistent project.

Adds HTTP-only events/subscribe and events/unsubscribe handlers backed by Appwrite project webhooks, with caller authorization, sealed subscription state, callback verification, TTLs, and expired-webhook cleanup. Adds an Appwrite REST emulator and end-to-end flows for subscription, delivery, refresh, secret rotation, cleanup, error handling, and unsubscribe, alongside documentation and telemetry updates.

2 of 20 changed files were too large to include in full.

Verdict New comments Fixed Still open
💬 Commented 1 0 0

Note

Not approving while a bug finding is open: Map a missing project on unsubscribe to Forbidden

Finding Where
🟡 Map a missing project on unsubscribe to Forbidden src/mcp_server_appwrite/events/subscriptions.py:564
Fix with agent prompt
### Issue 1
src/mcp_server_appwrite/events/subscriptions.py:564-565
**Map a missing project on unsubscribe to Forbidden**

When `events/unsubscribe` targets a deleted or nonexistent project, `webhooks.get()` returns `404 project_not_found` and `_remove` reaches this fallback, producing `-32603` instead of the documented `-32012`. Mapping this Appwrite error type to Forbidden would keep a caller-side project error from being reported as an internal failure.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 21
File Change
AGENTS.md Updates the events module overview to include the new subscription handlers.
README.md Reframes the events documentation link as a feature in development.
docs/events.md Documents subscription and unsubscribe behavior, webhook fields, TTLs, errors, and testing.
docs/flags.md Updates the events testing-flag description for the new methods.
src/mcp_server_appwrite/events/catalog.py Adds per-event authorization reads and required scopes.
src/mcp_server_appwrite/events/errors.py Adds internal-error construction and expected-error classification.
src/mcp_server_appwrite/events/ingress.py Exposes the shared keyring, egress, and dispatcher to 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, webhook upsert/cleanup, and unsubscribe.
src/mcp_server_appwrite/events/webhooks.py Defines managed webhook labels and the Appwrite webhook service wrapper.
src/mcp_server_appwrite/http_app.py Shares the mounted ingress with the MCP server's subscription handlers.
src/mcp_server_appwrite/server.py Passes the ingress and request identity callback into event protocol registration.
src/mcp_server_appwrite/telemetry.py Adds subscription outcome metrics.
tests/e2e/support.py Adds an Appwrite REST emulator and multi-user token claims for subscription flows.
tests/e2e/test_events_ingress.py Updates ingress test setup to use the managed webhook username and explain hand-built cases.
tests/e2e/test_events_protocol.py Updates method availability assertions for the events flag.
tests/e2e/test_events_subscribe.py Adds end-to-end tests for subscriptions, delivery, authorization, cleanup, errors, and unsubscribe.
tests/unit/test_events_egress.py Removes URL-shape unit cases now exercised through hosted-server flows.
tests/unit/test_events_catalog.py Removes catalog cases covered by subscription end-to-end flows.
tests/unit/test_events_delivery.py Removes secret and verification cases covered by subscription end-to-end flows.
tests/unit/test_events_envelope.py Removes subscription-id cases covered by subscription end-to-end flows.

Reviewed 5dc7a55 · 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 on lines +564 to +565
return EventsError.internal(
f"Appwrite could not save the subscription (HTTP {code or 'error'})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Map a missing project on unsubscribe to Forbidden

When events/unsubscribe targets a deleted or nonexistent project, webhooks.get() returns 404 project_not_found and _remove reaches this fallback, producing -32603 instead of the documented -32012. Mapping this Appwrite error type to Forbidden would keep a caller-side project error from being reported as an internal failure.

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

Comment:
**Map a missing project on unsubscribe to Forbidden**

When `events/unsubscribe` targets a deleted or nonexistent project, `webhooks.get()` returns `404 project_not_found` and `_remove` reaches this fallback, producing `-32603` instead of the documented `-32012`. Mapping this Appwrite error type to Forbidden would keep a caller-side project error from being reported as an internal failure.

---

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

🟡 Minor · error-handling · Reply if this doesn't apply.

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