mcp-event-subscriptions 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ """Experimental webhook subscription records and a restricted JWE profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping
10
+ from datetime import UTC, datetime
11
+ from importlib.resources import files
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from jsonschema import Draft202012Validator, FormatChecker
16
+ from jwcrypto import jwe, jwk
17
+
18
+ MAX_BYTES = 64 * 1024
19
+ PROFILE = "mcp-subscription-envelope-v1"
20
+ HEADER = {
21
+ "alg": "dir",
22
+ "enc": "A256GCM",
23
+ "typ": "mcp-subscription+jwe",
24
+ "cty": "application/json",
25
+ }
26
+ SCHEMA = json.loads(
27
+ files(__package__).joinpath("subscription-v1.schema.json").read_text()
28
+ )
29
+ VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
30
+
31
+
32
+ class RecordError(ValueError):
33
+ """Invalid subscription data. Messages do not include the record."""
34
+
35
+
36
+ class EnvelopeError(ValueError):
37
+ """Invalid, unauthenticated, or incorrectly bound envelope."""
38
+
39
+
40
+ class ExpiredSubscription(EnvelopeError):
41
+ """The recovered subscription lease has expired."""
42
+
43
+
44
+ def _pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
45
+ result: dict[str, Any] = {}
46
+ for key, value in pairs:
47
+ if key in result:
48
+ raise ValueError("duplicate JSON key")
49
+ result[key] = value
50
+ return result
51
+
52
+
53
+ def _constant(value: str) -> Any:
54
+ raise ValueError("non-finite JSON value")
55
+
56
+
57
+ def _loads(value: str | bytes) -> Any:
58
+ return json.loads(value, object_pairs_hook=_pairs, parse_constant=_constant)
59
+
60
+
61
+ def _encode(value: Any) -> bytes:
62
+ return json.dumps(
63
+ value, sort_keys=True, separators=(",", ":"), allow_nan=False
64
+ ).encode()
65
+
66
+
67
+ def _json_value(value: Any) -> None:
68
+ if value is None or type(value) in (str, bool, int, float):
69
+ return
70
+ if type(value) is list:
71
+ for item in value:
72
+ _json_value(item)
73
+ return
74
+ if type(value) is dict and all(type(k) is str for k in value):
75
+ for item in value.values():
76
+ _json_value(item)
77
+ return
78
+ raise ValueError("non-JSON value")
79
+
80
+
81
+ def _stamp(value: str) -> datetime:
82
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
83
+
84
+
85
+ def validate_subscription(record: Any) -> dict[str, Any]:
86
+ """Validate structure and semantics; return an independent JSON copy."""
87
+ try:
88
+ _json_value(record)
89
+ encoded = _encode(record)
90
+ if len(encoded) > MAX_BYTES or not VALIDATOR.is_valid(record):
91
+ raise ValueError("invalid structure or size")
92
+ url = urlsplit(record["delivery"]["url"])
93
+ # DNS, SSRF policy, and endpoint verification remain adapter responsibilities.
94
+ if (
95
+ url.scheme != "https"
96
+ or not url.hostname
97
+ or "@" in url.netloc
98
+ or "#" in record["delivery"]["url"]
99
+ or any(c.isspace() or ord(c) < 32 for c in record["delivery"]["url"])
100
+ ):
101
+ raise ValueError("invalid callback URL")
102
+ if url.port is not None and not 1 <= url.port <= 65535:
103
+ raise ValueError("invalid callback port")
104
+ secret = record["delivery"]["secret"][6:]
105
+ raw = base64.b64decode(secret, validate=True)
106
+ if not 24 <= len(raw) <= 64 or base64.b64encode(raw).decode() != secret:
107
+ raise ValueError("invalid secret")
108
+ expiry, refresh = (
109
+ record["lease"]["expiresAt"],
110
+ record["lease"]["refreshBefore"],
111
+ )
112
+ if (expiry is None) != (refresh is None):
113
+ raise ValueError("inconsistent lease")
114
+ if expiry is not None and _stamp(refresh) > _stamp(expiry):
115
+ raise ValueError("invalid lease order")
116
+ return _loads(encoded)
117
+ except (ValueError, TypeError, KeyError, OverflowError, RecursionError) as error:
118
+ raise RecordError("invalid subscription record") from error
119
+
120
+
121
+ def _context(context: Any, resource: str) -> dict[str, str]:
122
+ if (
123
+ type(context) is not dict
124
+ or set(context) != {"server", "tenant", "resource"}
125
+ or any(type(v) is not str or not v for v in context.values())
126
+ or context["resource"] != resource
127
+ ):
128
+ raise EnvelopeError("invalid envelope context")
129
+ return dict(context)
130
+
131
+
132
+ def _b64(value: bytes) -> str:
133
+ return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
134
+
135
+
136
+ def _unb64(value: str) -> bytes:
137
+ if re.fullmatch(r"[A-Za-z0-9_-]*", value) is None:
138
+ raise EnvelopeError("invalid base64url")
139
+ try:
140
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
141
+ except (ValueError, binascii.Error) as error:
142
+ raise EnvelopeError("invalid base64url") from error
143
+ if _b64(raw) != value:
144
+ raise EnvelopeError("noncanonical base64url")
145
+ return raw
146
+
147
+
148
+ def _key(keys: Mapping[str, bytes], kid: str) -> jwk.JWK:
149
+ if type(kid) is not str or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", kid) is None:
150
+ raise EnvelopeError("invalid key identifier")
151
+ material = keys.get(kid)
152
+ if type(material) is not bytes or len(material) != 32:
153
+ raise EnvelopeError("unknown or invalid encryption key")
154
+ return jwk.JWK(kty="oct", k=_b64(material))
155
+
156
+
157
+ def seal(
158
+ record: Any,
159
+ keys: Mapping[str, bytes],
160
+ *,
161
+ active_key: str,
162
+ context: dict[str, str],
163
+ ) -> str:
164
+ """Encrypt a validated record with a random IV and trusted storage context."""
165
+ record = validate_subscription(record)
166
+ context = _context(context, record["id"])
167
+ key = _key(keys, active_key)
168
+ payload = _encode({"profile": PROFILE, "context": context, "subscription": record})
169
+ token = jwe.JWE(
170
+ payload,
171
+ protected=_encode({**HEADER, "kid": active_key}).decode(),
172
+ algs=["dir", "A256GCM"],
173
+ )
174
+ token.add_recipient(key)
175
+ compact = token.serialize(compact=True)
176
+ if len(compact.encode()) > MAX_BYTES:
177
+ raise EnvelopeError("envelope exceeds size limit")
178
+ return compact
179
+
180
+
181
+ def open_envelope(
182
+ token: str,
183
+ keys: Mapping[str, bytes],
184
+ *,
185
+ expected_context: dict[str, str],
186
+ now: datetime | None = None,
187
+ ) -> dict[str, Any]:
188
+ """Authenticate, validate, bind and check expiry before returning a record."""
189
+ try:
190
+ if type(token) is not str or len(token) > MAX_BYTES or not token.isascii():
191
+ raise EnvelopeError("invalid envelope size or encoding")
192
+ parts = token.split(".")
193
+ if len(parts) != 5 or parts[1]:
194
+ raise EnvelopeError("invalid compact JWE")
195
+ header_bytes, _, iv, ciphertext, tag = map(_unb64, parts)
196
+ header = _loads(header_bytes)
197
+ if (
198
+ type(header) is not dict
199
+ or set(header) != set(HEADER) | {"kid"}
200
+ or any(header[k] != v for k, v in HEADER.items())
201
+ or len(iv) != 12
202
+ or len(tag) != 16
203
+ or not ciphertext
204
+ ):
205
+ raise EnvelopeError("unsupported envelope header or framing")
206
+ key = _key(keys, header["kid"])
207
+ decoded = jwe.JWE(algs=["dir", "A256GCM"])
208
+ decoded.deserialize(token, key=key)
209
+ payload = _loads(decoded.payload)
210
+ if (
211
+ type(payload) is not dict
212
+ or set(payload) != {"profile", "context", "subscription"}
213
+ or payload["profile"] != PROFILE
214
+ ):
215
+ raise EnvelopeError("unsupported envelope payload")
216
+ record = validate_subscription(payload["subscription"])
217
+ expected = _context(expected_context, record["id"])
218
+ if _context(payload["context"], record["id"]) != expected:
219
+ raise EnvelopeError("envelope context mismatch")
220
+ current = now if now is not None else datetime.now(UTC)
221
+ if current.tzinfo is None or current.utcoffset() is None:
222
+ raise EnvelopeError("clock must be timezone-aware")
223
+ expiry = record["lease"]["expiresAt"]
224
+ if expiry is not None and current >= _stamp(expiry):
225
+ raise ExpiredSubscription("subscription expired")
226
+ return record
227
+ except EnvelopeError:
228
+ raise
229
+ except Exception as error:
230
+ # No raw token, secret, or decrypted record in the public error message.
231
+ raise EnvelopeError("invalid encrypted subscription") from error
232
+
233
+
234
+ __all__ = [
235
+ "EnvelopeError",
236
+ "ExpiredSubscription",
237
+ "RecordError",
238
+ "open_envelope",
239
+ "seal",
240
+ "validate_subscription",
241
+ ]
@@ -0,0 +1,101 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://chiragagg5k.github.io/mcp-event-subscriptions/schemas/subscription-v1.schema.json",
4
+ "title": "Experimental MCP webhook subscription record v1",
5
+ "type": "object",
6
+ "properties": {
7
+ "version": {
8
+ "type": "integer",
9
+ "const": 1
10
+ },
11
+ "id": {
12
+ "type": "string",
13
+ "minLength": 1
14
+ },
15
+ "principal": {
16
+ "type": "string",
17
+ "minLength": 1
18
+ },
19
+ "event": {
20
+ "type": "object",
21
+ "properties": {
22
+ "name": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "arguments": {
27
+ "type": "object"
28
+ }
29
+ },
30
+ "required": [
31
+ "name",
32
+ "arguments"
33
+ ],
34
+ "additionalProperties": false
35
+ },
36
+ "delivery": {
37
+ "type": "object",
38
+ "properties": {
39
+ "mode": {
40
+ "const": "webhook"
41
+ },
42
+ "url": {
43
+ "type": "string",
44
+ "format": "uri",
45
+ "pattern": "^https://"
46
+ },
47
+ "secret": {
48
+ "type": "string",
49
+ "pattern": "^whsec_[A-Za-z0-9+/]+={0,2}$"
50
+ }
51
+ },
52
+ "required": [
53
+ "mode",
54
+ "url",
55
+ "secret"
56
+ ],
57
+ "additionalProperties": false
58
+ },
59
+ "lease": {
60
+ "type": "object",
61
+ "properties": {
62
+ "expiresAt": {
63
+ "type": [
64
+ "string",
65
+ "null"
66
+ ],
67
+ "format": "date-time",
68
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,6})?Z$"
69
+ },
70
+ "refreshBefore": {
71
+ "type": [
72
+ "string",
73
+ "null"
74
+ ],
75
+ "format": "date-time",
76
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,6})?Z$"
77
+ }
78
+ },
79
+ "required": [
80
+ "expiresAt",
81
+ "refreshBefore"
82
+ ],
83
+ "additionalProperties": false
84
+ },
85
+ "extensions": {
86
+ "type": "object",
87
+ "propertyNames": {
88
+ "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*){2,}$"
89
+ }
90
+ }
91
+ },
92
+ "required": [
93
+ "version",
94
+ "id",
95
+ "principal",
96
+ "event",
97
+ "delivery",
98
+ "lease"
99
+ ],
100
+ "additionalProperties": false
101
+ }
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-event-subscriptions
3
+ Version: 0.1.0a1
4
+ Summary: Experimental portable MCP webhook subscription records and JWE envelopes
5
+ Project-URL: Repository, https://github.com/ChiragAgg5k/mcp-event-subscriptions
6
+ Project-URL: Documentation, https://github.com/ChiragAgg5k/mcp-event-subscriptions#readme
7
+ Project-URL: Issues, https://github.com/ChiragAgg5k/mcp-event-subscriptions/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: jsonschema<5,>=4.23
17
+ Requires-Dist: jwcrypto<2,>=1.5.6
18
+ Description-Content-Type: text/markdown
19
+
20
+ # MCP Event Subscriptions
21
+
22
+ An experimental, portable subscription record for MCP Events, with an optional
23
+ [JWE](https://www.rfc-editor.org/rfc/rfc7516) encrypted envelope.
24
+
25
+ Store the same record in a database, a key-value store, or an upstream webhook
26
+ configuration. This repository defines its fields, encrypted encoding, and
27
+ validation rules, with a Python reference implementation and conformance vectors.
28
+
29
+ **Independent proposal. This is not an adopted MCP specification or an IETF RFC.**
30
+ The format may change before a stable release. It does not change MCP event
31
+ requests or notification payloads.
32
+
33
+ ## What is here
34
+
35
+ - [Record specification](SPEC.md) and [JSON Schema](schemas/subscription-v1.schema.json).
36
+ - [Encrypted envelope profile](profiles/encrypted-envelope.md): JWE Compact,
37
+ `dir` + `A256GCM`, with explicit server/tenant/resource binding.
38
+ - [Appwrite webhook profile](profiles/appwrite-webhook.md): proposed storage
39
+ mapping and the limits of write-only upstream state.
40
+ - [Examples](examples/) and [conformance vectors](test-vectors/README.md).
41
+ - [Python reference implementation](packages/python/README.md).
42
+ - [Upstream discussion draft](proposals/upstream-mcp.md).
43
+
44
+ ## Install the alpha release
45
+
46
+ Once the first release is published to PyPI:
47
+
48
+ ```sh
49
+ pip install mcp-event-subscriptions==0.1.0a1
50
+ ```
51
+
52
+ See [RELEASING.md](RELEASING.md) for the Trusted Publishing setup and release process.
53
+
54
+ ## Quick start
55
+
56
+ Python 3.12 or newer and [uv](https://docs.astral.sh/uv/):
57
+
58
+ ```sh
59
+ uv sync --group dev
60
+ uv run python examples/roundtrip.py
61
+ uv run python -m unittest discover -s tests -v
62
+ ```
63
+
64
+ ```python
65
+ import json
66
+ import secrets
67
+ from mcp_event_subscriptions import seal, open_envelope
68
+
69
+ record = json.load(open("examples/subscription.json"))
70
+ context = {
71
+ "server": "https://mcp.example.com",
72
+ "tenant": "project_demo",
73
+ "resource": record["id"],
74
+ }
75
+ keys = {"k1": secrets.token_bytes(32)}
76
+ token = seal(record, keys, active_key="k1", context=context)
77
+ restored = open_envelope(token, keys, expected_context=context)
78
+ assert restored == record
79
+ ```
80
+
81
+ The sample subscription expires in 2030. Examples and vectors use synthetic
82
+ principals and secrets. Fixed test-vector keys are public fixtures, never
83
+ production keys.
84
+
85
+ ## Scope
86
+
87
+ Version 1 describes webhook subscription state and optional encrypted storage.
88
+ It does not specify a SQL layout, implement a delivery queue, establish current
89
+ caller authorization, or make refresh/delete operations atomic. Adapters must
90
+ provide their own coordination and revocation checks. Encryption cannot detect
91
+ replay of an older, still-valid envelope without current state.
92
+
93
+ The Appwrite profile is documentation, not a working Appwrite adapter. It is not
94
+ wire-compatible with the existing `v1.<kid>.<ciphertext>` envelope in
95
+ [appwrite/mcp PR #132](https://github.com/appwrite/mcp/pull/132).
96
+
97
+ ## Discussing the proposal
98
+
99
+ Start with the [open questions](proposals/upstream-mcp.md#open-questions): which
100
+ fields should be common, whether an encrypted profile belongs alongside the
101
+ schema, and what lifecycle guarantees adapters must expose. No proposal has
102
+ been submitted upstream by this repository.
103
+
104
+ ## Development
105
+
106
+ ```sh
107
+ uv run --group dev ruff check .
108
+ uv run --group dev black --check .
109
+ uv run python -m unittest discover -s tests -v
110
+ uv build
111
+ ```
112
+
113
+ Licensed under the MIT License. See [SECURITY.md](SECURITY.md) before using the
114
+ reference implementation with real secrets.
@@ -0,0 +1,6 @@
1
+ mcp_event_subscriptions/__init__.py,sha256=CN7ecNATFiIu64p1aJ386h89aDTPVPdeKuakk-jsN1o,8301
2
+ mcp_event_subscriptions/subscription-v1.schema.json,sha256=8fkYQerIm6TBmj357PxqSC0cYUvEtNBtL7y9WXiX9VU,2194
3
+ mcp_event_subscriptions-0.1.0a1.dist-info/METADATA,sha256=oBcz0pGnouODsu9wKxdyS8eKyIJw0TdCeyVJi_9NiI8,4268
4
+ mcp_event_subscriptions-0.1.0a1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ mcp_event_subscriptions-0.1.0a1.dist-info/licenses/LICENSE,sha256=FDRCGLH-p80m3w5GPU7K3VIJ00j37PKHvbAfViPs5aY,1065
6
+ mcp_event_subscriptions-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Appwrite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.