96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269 | class WebhookSubscriptions:
"""The live webhook subscriptions of one running server, and their delivery loops."""
def __init__(
self, config: Webhooks, client: httpx2.AsyncClient, task_group: TaskGroup, poll_interval: timedelta
) -> None:
self._config = config
self._poll_interval = poll_interval
self._callbacks = Callbacks(client, config.trusted_origins)
self._task_group = task_group
self._subscriptions: dict[str, _Subscription] = {}
self._verified: set[tuple[str | None, str]] = set()
self._setup = KeyedLocks()
async def subscribe(
self, event_type: EventType[Any, Any], arguments: BaseModel, principal: str, params: SubscribeEventsParams
) -> SubscribeEventsResult:
url, secret = params.delivery.url, params.delivery.secret
try:
self._callbacks.validate(url)
decode_secret(secret)
except ValueError as exc:
raise MCPError(code=INVALID_PARAMS, message=str(exc)) from None
key = json.dumps([principal, url, event_type.name, params.arguments or {}], sort_keys=True)
subscription_id = f"sub_{hashlib.sha256(key.encode()).hexdigest()[:16]}"
ttl = self._grant(params)
async with self._setup(key):
if (existing := self._subscriptions.get(key)) is not None:
return self._refresh(existing, secret, ttl)
try:
return await self._create(event_type, arguments, principal, params, key, subscription_id, ttl)
except BaseException:
self._forget_unused_verification(principal, url)
raise
async def _create(
self,
event_type: EventType[Any, Any],
arguments: BaseModel,
principal: str,
params: SubscribeEventsParams,
key: str,
subscription_id: str,
ttl: timedelta,
) -> SubscribeEventsResult:
url, secret = params.delivery.url, params.delivery.secret
if (principal, url) not in self._verified:
try:
await self._callbacks.verify(url, subscription_id, secret)
except NonPublicAddress as exc:
raise MCPError(code=INVALID_PARAMS, message=str(exc)) from None
except CallbackFailure as exc:
raise events_error(CALLBACK_ENDPOINT_ERROR, reason=exc.reason) from None
self._verified.add((principal, url))
max_age = timedelta(milliseconds=params.max_age_ms) if params.max_age_ms is not None else None
batch = await event_type.read(EventQuery(arguments, params.cursor, principal, max_age))
subscriber = Subscriber(id=subscription_id, arguments=arguments, principal=principal, mode="webhook")
subscription = _Subscription(
id=subscription_id,
key=key,
event_type=event_type,
subscriber=subscriber,
url=url,
secret=secret,
expires_at=datetime.now(UTC) + ttl,
# The batch is not delivered yet, so only the supplied cursor is safe to persist.
cursor=params.cursor if batch.events else batch.cursor,
)
subscription.scope.deadline = anyio.current_time() + ttl.total_seconds()
for hook in event_type.subscribe_hooks:
await hook(subscriber)
self._subscriptions[key] = subscription
self._task_group.start_soon(self._run, subscription, batch)
return SubscribeEventsResult(
id=subscription_id,
refresh_before=subscription.expires_at,
cursor=subscription.cursor,
truncated=batch.truncated,
)
def unsubscribe(self, event_type: EventType[Any, Any], principal: str, params: UnsubscribeEventsParams) -> None:
key = json.dumps([principal, params.delivery.url, event_type.name, params.arguments or {}], sort_keys=True)
subscription = self._subscriptions.pop(key, None)
if subscription is None:
raise events_error(NOT_FOUND, kind="subscription")
subscription.scope.cancel()
def _forget_unused_verification(self, principal: str | None, url: str) -> None:
# The spec scopes verification to the subscriptions it covers, so the cache cannot outgrow them.
if not any(s.subscriber.principal == principal and s.url == url for s in self._subscriptions.values()):
self._verified.discard((principal, url))
def _grant(self, params: SubscribeEventsParams) -> timedelta:
# In-memory subscriptions cannot honour "no expiry", so an omitted or null ttlMs gets the cap.
if params.ttl_ms is None:
return self._config.max_ttl
return min(max(timedelta(milliseconds=params.ttl_ms), self._config.min_ttl), self._config.max_ttl)
def _refresh(self, subscription: _Subscription, secret: str, ttl: timedelta) -> SubscribeEventsResult:
if secret != subscription.secret:
subscription.previous_secret = (subscription.secret, datetime.now(UTC) + _ROTATION_GRACE)
subscription.secret = secret
subscription.expires_at = datetime.now(UTC) + ttl
subscription.scope.deadline = anyio.current_time() + ttl.total_seconds()
return SubscribeEventsResult(
id=subscription.id,
refresh_before=subscription.expires_at,
cursor=subscription.cursor,
delivery_status=subscription.status(),
)
async def _run(self, subscription: _Subscription, batch: EventBatch[Any]) -> None:
event_type, subscriber = subscription.event_type, subscription.subscriber
try:
with subscription.scope:
while True:
# shortcut: events go out one at a time, so a failing event delays later ones by its
# retries, where the spec retries each independently; needs per-event acks behind the cursor.
for index, occurrence in enumerate(batch.events):
last = index == len(batch.events) - 1
body = event_type.wire(occurrence, batch.cursor if last else subscription.cursor)
await self._deliver(subscription, body.model_dump(mode="json", by_alias=True))
subscription.cursor = batch.cursor
delay = timedelta(0) if batch.has_more else batch.next_poll or self._poll_interval
await event_type.buffer.wait(delay.total_seconds())
batch = await self._read(subscription)
finally:
if self._subscriptions.get(subscription.key) is subscription:
del self._subscriptions[subscription.key]
self._forget_unused_verification(subscriber.principal, subscription.url)
for hook in event_type.unsubscribe_hooks:
await hook(subscriber)
async def _read(self, subscription: _Subscription) -> EventBatch[Any]:
query = EventQuery(subscription.subscriber.arguments, subscription.cursor, subscription.subscriber.principal)
try:
batch = await subscription.event_type.read(query)
except MCPError as exc:
await self._deliver(subscription, {"type": "terminated", "error": exc.error.model_dump(exclude_none=True)})
subscription.scope.cancel()
return EventBatch(cursor=subscription.cursor)
except Exception:
# The check function is user code: a crash must not take the server's task group down.
logger.exception("check function for %r raised", subscription.event_type.name)
return EventBatch(cursor=subscription.cursor)
if batch.truncated:
await self._deliver(subscription, {"type": "gap", "cursor": batch.cursor})
return batch
async def _deliver(self, subscription: _Subscription, body: dict[str, Any]) -> None:
kind = body.get("type", "event")
message_id = f"msg_{kind}_{secrets.token_hex(12)}" if kind != "event" else f"msg_{secrets.token_hex(16)}"
for delay in [*self._config.retry_delays, None]:
try:
response = await self._callbacks.post(
subscription.url, subscription.id, subscription.signing_secrets(), message_id, body
)
except NonPublicAddress:
logger.warning("abandoned a delivery to %s: it resolves to a non-public address", subscription.url)
subscription.last_error = "connection_refused"
return
except CallbackFailure as exc:
subscription.last_error = exc.reason
else:
if response.is_success:
subscription.last_delivery_at = datetime.now(UTC)
subscription.last_error = subscription.failed_since = None
return
subscription.last_error = "http_5xx" if response.status_code >= 500 else "http_4xx"
if response.status_code in (410, 413):
return
subscription.failed_since = subscription.failed_since or datetime.now(UTC)
if delay is not None:
await anyio.sleep(delay.total_seconds())
|