Repository navigation
feat: seal MCP event subscriptions into an envelope - #132
Open
ChiragAgg5k wants to merge 6 commits into
Open
ChiragAgg5k wants to merge 6 commits into
ChiragAgg5k wants to merge 6 commits into
Conversation
🟢 Tier S · Ready to mergeThis pull request adds the hosted MCP Events catalog and protocol support, plus stateless subscription envelopes, key management, and Appwrite webhook-signature helpers. The newest commits adapt the envelope implementation to the Latest changes: The latest commits replace the custom AES-GCM envelope format with the external JWE profile, adapt subscriptions and context binding to its record format, and add a conformance-vector test and dependency.
📂 Walkthrough · 10
Reviewed the commits since |
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.
ChiragAgg5k
force-pushed
the
feat/events-envelope
branch
from
October 9, 2026 13:53
aef5109 to
a995f8f
Compare
This was referenced Oct 9, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 3 of 4 for MCP Events (#127). Adds
mcp_server_appwrite.events.envelope, the sealed subscription envelope that lets the hosted server stay stateless: each subscription is one Appwrite project webhook, and everything the ingress needs to deliver an event is sealed into that webhook'sauthPassword, which Appwrite sends back as HTTP Basic auth on every delivery.The envelope format is not ours: it is the
mcp-subscription+jweencrypted envelope profile of mcp-event-subscriptions (mcp-event-subscriptions0.1.0a1 on PyPI), sealing a Subscription Record v1.envelope.pyis the thin adapter around it.This is a self-contained library. Nothing is wired into
server.pyorhttp_app.py; the subscribe and ingress PRs consume it.What's in it:
Subscriptionfrozen dataclass, the typed view of the record:id,project,name,arguments(sorted, read-only mapping),callback,secret(onewhsec_secret, kept out ofrepr),expiresandrefresh(epoch ms, written as the record'slease.expiresAt/lease.refreshBefore),principal(digest, never raw tokens).Subscription.recordbuilds the record,Subscription.from_recordreads it back and refuses one whose id does not hash from its contents.Context(server, tenant, resource): what an envelope is bound to.Keyring(fromMCP_EVENTS_SEALING_KEYS) withseal(subscription, server),open(envelope, context)andrecord(envelope, context)(both take an optionalnowthat only fixtures pin), pluskey_id(envelope)to read thekidfrom the JWE header before opening.EnvelopeErrorwith anEnvelopeFailurereason (malformed,unknown_key,authentication_failed,binding_mismatch),SubscriptionExpired(kept apart so the ingress can answer "expired" with a 2xx drop and "invalid" with 401),EnvelopeTooLarge,InvalidSubscription,KeyringError.Principal(issuer, subject, client).digest: base64url of the first 128 bits of SHA-256 over canonical JSON of OAuthiss,sub,client_id(22 chars).subscription_id(principal, callback, name, arguments):sub_+ 32 hex chars of SHA-256 over canonical JSON. Exactly 36 chars, valid for Appwrite'sCustomId/Keyvalidator. Argument order does not change it.valid_subscription_idaccepts exactlysub_[0-9a-f]{32}.secret(Keyring.signing_key) andappwrite_signature/verify_appwrite_signatureforX-Appwrite-Webhook-Signature.mcp-event-subscriptions>=0.1.0a1,<0.2(bringsjwcrypto1.6.1;jsonschema4.26.0 was already locked).cryptographystays a direct dependency because the signing key derivation imports its HKDF.Format
JWE Compact, exactly as the profile specifies:
{"alg":"dir","enc":"A256GCM","kid":"<key id>","typ":"mcp-subscription+jwe","cty":"application/json"}, the empty encrypted-key segment ofdir, a random 96-bit IV per seal. The sealing key is the A256GCM key.{"profile":"mcp-subscription-envelope-v1","context":{"server","tenant","resource"},"subscription":<record>}. The record holdsversion: 1,id,principal,event.{name,arguments},delivery.{mode:"webhook",url,secret},lease.{expiresAt,refreshBefore}(UTC RFC 3339 with milliseconds andZ) andextensions: {}.serveris the stable deployment id fromMCP_PUBLIC_URL,tenantthe project id,resourcethe subscription id (which is also the webhook id). When opening, the expected context comes only from trusted configuration, the routed webhook id andX-Appwrite-Webhook-Project-Id, never from the token. An envelope copied onto another webhook, project or deployment does not open. On top of the profile, the record must hash back to the subscription id (binding_mismatchotherwise).SubscriptionExpired.[A-Za-z0-9._-], safe as a Basic auth password. Appwrite only sends Basic auth whenauthUsernameis non-empty too, so the subscribe PR sets a fixed, non-secret username.Example (throwaway key;
tablesdb.row.created, 59-char callback URL, one 32-byte secret):Size budget
Measured with 36-char Appwrite ids, a 22-char principal digest, key id
k1and serverhttps://mcp.appwrite.io. Secrets arewhsec_+ base64 of the stated byte length.tablesdb.row.created, 59-char URL, one 32-byte secretusers.user.created, 100-char URL, one 32-byte secrettablesdb.row.created, 150-char URL, one 32-byte secretfunctions.deployment.completed+status, 300-char URL, one 64-byte secrettablesdb.row.created, 300-char URL, one 64-byte secretENVELOPE_BUDGET(sealraisesEnvelopeTooLargeabove it)authPasswordtoday: reliable limit (encrypted column)authPasswordtoday: API validatorText(256)The JWE envelope is roughly 550 to 680 chars longer than the previous custom format (base64url header, JSON wrapper, readable record keys, context), and still about 470 chars under the budget in the worst case. What appwrite/appwrite#14293 must allow is unchanged:
authPasswordof at leastENVELOPE_BUDGET= 2048 characters, stored intact, i.e. the column change already proposed there (httpPass→VAR_TEXT65535) and raising theauthPasswordparam validator on create and update fromText(256)to at leastText(2048). Subscribe mapsEnvelopeTooLargeto-32602(callback URL too long).Key management
MCP_EVENTS_SEALING_KEYS=<id>:<base64 of 32 random bytes>[,<id>:<base64 of 32 random bytes>...], read from the environment like the other hosted config (Keyring.from_env()), documented in.env.exampleanddocs/development.md. In production it comes from Parameter Store.kidsyntax ([A-Za-z0-9_-]{1,128}) and must be unique. A missing variable raisesKeyringErrorwith a generation hint (openssl rand -base64 32).kidnamed in the header (no trial decryption).secretishex(HMAC-SHA256(HKDF-SHA256(sealing key, "mcp-events/appwrite-signature/v1"), subscription id)): 64 chars, within Appwrite'sText(256, 8). The ingress derives it from the ring, so no storage is needed.base64(HMAC-SHA1(url . body, secret))(src/Appwrite/Platform/Workers/Webhooks.php), whereurlis the webhook's configured URL. The ingress must rebuild the canonical ingress URL, not trust the URL the request arrived on.Tests
Envelope behavior is covered end to end by the ingress flows in #134 (
tests/e2e/test_events_ingress.py), which run against the real server:Keyring.sealand the ingress'sopen.401 envelope): a flipped envelope byte; an envelope for another project; one copied from another webhook; one sealed with the server's key for the same webhook and project but by another deployment (anotherMCP_PUBLIC_URL); one with a header that names a ring key but not the profile's algorithm pair; one sealed with the server's own key for this webhook and project but holding another subscription's contents (binding_mismatch).401 credentials): the oldv1.k1.…format, too many parts, a non-empty encrypted-key segment, a header that is not base64url JSON, a header withoutkid, an invalidkid. Foreign path ids such assub_zzzz…orsub_AAAA…also get401 credentials.k1delivers; restarted withk2,k1, the old envelope still delivers and ak2one does too; restarted withk2only, thek1envelope gets200 retired_keyand nothing is delivered.200 dropped expiredand nothing is delivered.MCP_EVENTS_SEALING_KEYSis missing, and short, all-zero, repeated, non-base64, id-less and duplicate-id keys stop startup withKeyringError.Unit tests kept (
tests/unit/test_events_envelope.py, 11 tests), and why they are not e2e:test-vectors/encryption/jwe-v1.json, copied unchanged totests/unit/fixtures/jwe-v1.json(the vector ships in the GitHub repo, not the wheel). Our keyring parses its key,Keyring.recordopens its token (clock pinned to 2029, since the vector's lease ends in 2030) to exactly the published record,Keyring.openrefuses it only forbinding_mismatch(itssub_exampleid is not onesubscription_id()derives), and the published record maps field for field onto ourSubscription, whosereprdoes not show the secret. Every e2e envelope is sealed and opened by this same server, so only this vector ties our format to the profile.CustomIdrules. Foreign ids are refused, including Appwrite-valid ones that are not 32 lowercase hex characters. Also covered: the principal digest is stable and 22 chars, and the derived webhooksecretfits Appwrite'sText(256, 8). Nothing derives an id from a request untilevents/subscribe(PR 5), which replaces these with its e2e flow.ENVELOPE_BUDGET, and a 2048-char URL raisesEnvelopeTooLarge. This is the contract Encrypted and length-limited attributes return 500 instead of 400 (webhooks authPassword/authUsername/url, variables value) appwrite#14293 has to meet.Review changes
d14a220 (Hansi):
valid_subscription_idaccepted any 32-character Appwrite-safe suffix. It now accepts onlysub_followed by 32 lowercase hex characters, which is whatsubscription_id()emits.7ede499: the custom
v1.<kid>.<base64url(nonce | ciphertext | tag)>AES-GCM format is replaced by themcp-subscription+jweprofile frommcp-event-subscriptions. Deleted: the AES-GCM/HKDF seal key, the one-letter plaintext, the custom associated data and the envelope parser, and the per-case size tests (one budget test stays). The record holds onedelivery.secretinstead of a secrets tuple, matching the no-dual-sign behaviour of subscribe. Binding moved from AES-GCM associated data to the profile's encrypted context, which now also binds the server. Adds the published-vector test above.d4d5931, 350cea4 (Hansi): the vector's lease ends in 2030, so
Keyring.record/Keyring.opentake an optionalnowand the vector test pins it, as the package's own tests do. ASubscription's secret is kept out of itsrepr(same finding asCallbackin feat: sign and deliver MCP events safely #133).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: 279 tests OKuv run --group e2e python -m unittest discover -s tests/e2e: 2 tests OK (about 3 s)docker build -t appwrite-mcp:jwe .: builds