Skip to content

feat: MCP Events behind the events flag - #136

Merged
ChiragAgg5k merged 43 commits into
mainfrom
feat/events
Oct 12, 2026
Merged

ChiragAgg5k merged 43 commits into
mainfrom
feat/events

Conversation

@ChiragAgg5k

Copy link
Copy Markdown
Member

Summary

Adds MCP Events (webhook delivery, as ChatGPT uses it) to the hosted server, behind the events testing flag (MCP_EVENTS, default off). With the flag off, nothing changes: no capability is advertised, the events/* methods return -32601, and /appwrite/webhooks/* is not mounted (covered by EventsDisabledFlow).

Closes nothing yet; tracks #127. The flag stays off until appwrite/appwrite#14293 is on Cloud, because the sealed subscription envelope (about 1.1–1.6k characters) does not fit Appwrite's current authPassword storage.

Reviewed and merged as a stack into feat/events:

Design

The server keeps no state. Each subscription is one Appwrite webhook in the subscriber's project, whose id is the subscription id and whose Basic auth password is the sealed envelope. See docs/events.md.

Known limits

Enabling later

  1. Encrypted and length-limited attributes return 500 instead of 400 (webhooks authPassword/authUsername/url, variables value) appwrite#14293 deployed to Cloud.
  2. Set MCP_EVENTS_SEALING_KEYS (see docs/development.md) and MCP_EVENTS=1 on the deployment.
  3. Live e2e on Cloud staging with real OAuth, then the ChatGPT checklist.

Test plan

  • Lint, format, pyright, unit, e2e, Docker build, integration green on every stacked PR
  • E2E: protocol, ingress (16), subscribe/unsubscribe (25), flag-off flow

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).
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.
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.
It needs no credentials, so unlike integration it also runs for forks.
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.
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.
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.
…file

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.
… 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.
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.
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.
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.
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.
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.
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.
…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.
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: advertise MCP events and list the event catalog
feat: seal MCP event subscriptions into an envelope
feat: sign and deliver MCP events safely
feat: receive Appwrite webhooks and forward MCP events
feat: serve events/subscribe and events/unsubscribe
@hansi-codes

hansi-codes Bot commented Oct 12, 2026

Copy link
Copy Markdown

🔵 Tier A · Mergeable after minor fixes

The feature is gated and broadly covered, but a non-null resume cursor is silently treated as a complete fresh subscription.

Adds an opt-in MCP Events surface for hosted HTTP, including event discovery, webhook-backed subscriptions, signed delivery, and an Appwrite webhook ingress. Adds end-to-end coverage and documentation for the feature flag, envelope/key handling, and delivery behavior.

26 of 43 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: Do not report a complete subscription for an unsupported cursor

Finding Where
🟡 Do not report a complete subscription for an unsupported cursor src/mcp_server_appwrite/events/subscriptions.py:294
Fix with agent prompt
### Issue 1
src/mcp_server_appwrite/events/subscriptions.py:294-295
**Do not report a complete subscription for an unsupported cursor**

When a client resubscribes with a non-null cursor, this path still returns `truncated: false` and creates a fresh Appwrite webhook without replaying anything. Since Appwrite cannot replay, events between that cursor and webhook creation are silently lost; reject non-null cursors or indicate the gap.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 8
File Change
.env.example, docs/development.md, docs/flags.md Document the events flag, sealing-key configuration, and local testing.
docs/events.md, README.md Describe the event catalog, subscription model, security, ingress, delivery, and limitations.
src/mcp_server_appwrite/events/* Add event catalog, protocol methods, sealed subscription envelopes, webhook management, SSRF-safe egress, delivery, and ingress.
src/mcp_server_appwrite/flags.py, src/mcp_server_appwrite/server.py, src/mcp_server_appwrite/http_app.py Wire the opt-in flag, MCP handlers, and conditional webhook route into the hosted server.
src/mcp_server_appwrite/telemetry.py Add metrics for event ingress, delivery, and subscription outcomes.
tests/e2e/*, tests/unit/*events* Add hosted HTTP flows, Appwrite webhook fixtures, and focused coverage for event protocol, envelopes, delivery, and egress.
.github/workflows/ci.yml, pyproject.toml, uv.lock Add e2e CI execution and the runtime and test dependencies.
AGENTS.md Update repository guidance for the events module and e2e test workflow.

Reviewed 4d0a54c · 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 +294 to +295
"cursor": None,
"truncated": False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do not report a complete subscription for an unsupported cursor

When a client resubscribes with a non-null cursor, this path still returns truncated: false and creates a fresh Appwrite webhook without replaying anything. Since Appwrite cannot replay, events between that cursor and webhook creation are silently lost; reject non-null cursors or indicate the gap.

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

Comment:
**Do not report a complete subscription for an unsupported cursor**

When a client resubscribes with a non-null cursor, this path still returns `truncated: false` and creates a fresh Appwrite webhook without replaying anything. Since Appwrite cannot replay, events between that cursor and webhook creation are silently lost; reject non-null cursors or indicate the gap.

---

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

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

@ChiragAgg5k
ChiragAgg5k merged commit 2fceccf into main Oct 12, 2026
6 checks passed
@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