Skip to content

Commit a5e0630

Browse files
committed
Serialize subscription setup and keep event cursors safe to persist
1 parent ad16a1c commit a5e0630

10 files changed

Lines changed: 305 additions & 62 deletions

File tree

‎docs/advanced/events.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ fell out of the upstream's retention window. Clients use it to know they missed
121121
from pydantic import BaseModel
122122

123123
from mcp.experimental.events.server import Events, Subscriber
124+
from mcp.experimental.events.types import FORBIDDEN, events_error
124125
from mcp.server.mcpserver import MCPServer
125126

126127

@@ -140,6 +141,8 @@ watched: dict[str, int] = {}
140141

141142
@incidents.on_subscribe
142143
async def start_watching(subscriber: Subscriber[IncidentFilter]) -> None:
144+
if subscriber.principal is None:
145+
raise events_error(FORBIDDEN, reason="sign in to watch incidents")
143146
service = subscriber.arguments.service
144147
watched[service] = watched.get(service, 0) + 1
145148

@@ -152,7 +155,11 @@ async def stop_watching(subscriber: Subscriber[IncidentFilter]) -> None:
152155
mcp = MCPServer("pager", extensions=[events])
153156
```
154157

155-
Hooks let you set up and tear down upstream work per subscription. A poll client never says
158+
Hooks let you set up and tear down upstream work per subscription. `on_subscribe` is also where
159+
you check access: the subscriber carries the caller's `principal`, and raising an `MCPError`
160+
rejects the poll or subscription with that error.
161+
162+
A poll client never says
156163
goodbye, so the SDK leases poll subscriptions: `on_subscribe` runs on the first poll for a
157164
principal, event type, and arguments, and `on_unsubscribe` runs once no poll renewed the lease
158165
for `poll_lease` (three poll intervals by default).
@@ -221,7 +228,8 @@ anyio.run(main)
221228

222229
`watch` polls in a loop. It waits as long as the server asks (`nextPollMs`), never less than
223230
`Events(min_poll_interval=...)`, and polls again at once while the server has more. It yields
224-
only batches that have events or report a gap (`batch.truncated`).
231+
every batch, including empty ones, because an empty batch still moves the cursor. A batch with
232+
`batch.truncated` set means events were skipped.
225233

226234
To resume after a restart, save `batch.cursor` once you handled a batch, and pass it back with
227235
`watch(..., cursor=saved)`. Delivery is at least once, so deduplicate on `event_id`.
@@ -273,7 +281,8 @@ anyio.run(main)
273281
```
274282

275283
`subscribe` holds the subscription for the duration of the block. It refreshes it before it
276-
expires, and unsubscribes on exit.
284+
expires, passing the latest cursor so a server that lost the subscription replays from there,
285+
and unsubscribes on exit.
277286

278287
`WebhookReceiver` is an ASGI app for the callback URL. It answers the verification challenge,
279288
rejects requests that are unsigned, signed with another secret, or more than five minutes old,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
6+
import anyio
7+
8+
9+
class KeyedLocks:
10+
"""One lock per key, dropped once nobody holds or awaits it."""
11+
12+
def __init__(self) -> None:
13+
self._locks: dict[str, anyio.Lock] = {}
14+
15+
@asynccontextmanager
16+
async def __call__(self, key: str) -> AsyncIterator[None]:
17+
lock = self._locks.setdefault(key, anyio.Lock())
18+
try:
19+
async with lock:
20+
yield
21+
finally:
22+
if not lock.locked() and not lock.statistics().tasks_waiting:
23+
del self._locks[key]

‎src/mcp/experimental/events/client.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,18 @@ async def poll(
101101
async def watch(
102102
self, name: str, arguments: dict[str, Any] | None = None, *, cursor: str | None = None
103103
) -> AsyncGenerator[PollEventsResult, None]:
104-
"""Poll forever, yielding each batch that has events or reports a gap (`truncated`).
104+
"""Poll forever, yielding every batch, including empty ones.
105105
106106
Persist `batch.cursor` after handling a batch, and pass it back after a restart
107-
to resume: delivery is at-least-once, so deduplicate on `event_id`.
107+
to resume: delivery is at-least-once, so deduplicate on `event_id`. Empty batches
108+
still move the cursor, which keeps it inside the upstream's retention window.
108109
"""
109110
if cursor is None:
110111
cursor = (await self.poll(name, arguments)).cursor
111112
while True:
112113
batch = await self.poll(name, arguments, cursor=cursor)
113114
cursor = batch.cursor
114-
if batch.events or batch.truncated:
115-
yield batch
115+
yield batch
116116
if not batch.has_more:
117117
wait = max(timedelta(milliseconds=batch.next_poll_ms), self._min_poll_interval)
118118
await anyio.sleep(wait.total_seconds())
@@ -145,7 +145,7 @@ async def subscribe(
145145
)
146146
result = await self._session.send_request(SubscribeEventsRequest(params=params), SubscribeEventsResult)
147147
async with anyio.create_task_group() as task_group:
148-
task_group.start_soon(self._refresh, params, result.refresh_before)
148+
task_group.start_soon(self._refresh, params, result)
149149
try:
150150
yield result
151151
finally:
@@ -155,11 +155,10 @@ async def subscribe(
155155
request = UnsubscribeEventsRequest(params=unsubscribe)
156156
await self._session.send_request(request, UnsubscribeEventsResult)
157157

158-
async def _refresh(self, params: SubscribeEventsParams, refresh_before: datetime | None) -> None:
159-
while refresh_before is not None:
158+
async def _refresh(self, params: SubscribeEventsParams, result: SubscribeEventsResult) -> None:
159+
while result.refresh_before is not None:
160160
# Refresh at two thirds of the remaining lifetime, leaving room for a failed attempt.
161-
await anyio.sleep((refresh_before - datetime.now(UTC)).total_seconds() * 2 / 3)
162-
# Resend the original cursor-less params: the server keeps the subscription's own position.
163-
request = SubscribeEventsRequest(params=params.model_copy(update={"cursor": None}))
161+
await anyio.sleep((result.refresh_before - datetime.now(UTC)).total_seconds() * 2 / 3)
162+
# The latest safe cursor is a no-op for a live subscription, and the replay point if the server lost it.
163+
request = SubscribeEventsRequest(params=params.model_copy(update={"cursor": result.cursor}))
164164
result = await self._session.send_request(request, SubscribeEventsResult)
165-
refresh_before = result.refresh_before

‎src/mcp/experimental/events/receiver.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ class WebhookReceiver:
5353
"""ASGI app for the callback URL of webhook subscriptions made with one `secret`.
5454
5555
Requests that are unsigned, signed with another secret, or older than `tolerance`
56-
get a 401 and never reach `handler`. Events are deduplicated on `event_id` across
57-
the last `dedupe_window` events, since servers retry deliveries.
56+
get a 401 and never reach `handler`. Events are deduplicated on their subscription and
57+
`event_id` across the last `dedupe_window` events, since servers retry deliveries.
5858
"""
5959

6060
def __init__(
@@ -69,7 +69,7 @@ def __init__(
6969
self._handler = handler
7070
self._tolerance = tolerance
7171
self._dedupe_window = dedupe_window
72-
self._seen: OrderedDict[str, None] = OrderedDict()
72+
self._seen: OrderedDict[tuple[str, str], None] = OrderedDict()
7373

7474
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
7575
request = Request(scope, receive)
@@ -100,11 +100,13 @@ async def _handle(self, request: Request) -> Response:
100100
delivery = EventOccurrence.model_validate(payload)
101101
except (ValueError, TypeError, KeyError, AttributeError, ValidationError):
102102
return Response(status_code=400)
103-
if isinstance(delivery, EventOccurrence) and delivery.event_id in self._seen:
103+
# One upstream event can match several subscriptions, and each must receive it.
104+
seen_key = (subscription_id, delivery.event_id) if isinstance(delivery, EventOccurrence) else None
105+
if seen_key in self._seen:
104106
return Response(status_code=204)
105107
await self._handler(delivery)
106-
if isinstance(delivery, EventOccurrence):
107-
self._seen[delivery.event_id] = None
108+
if seen_key is not None:
109+
self._seen[seen_key] = None
108110
if len(self._seen) > self._dedupe_window:
109111
self._seen.popitem(last=False)
110112
return Response(status_code=204)

‎src/mcp/experimental/events/server.py‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def same_service(arguments: IncidentFilter, incident: Incident) -> bool:
3131
from pydantic import BaseModel, ValidationError
3232

3333
from mcp.experimental.events._buffer import EmitBuffer
34+
from mcp.experimental.events._locks import KeyedLocks
3435
from mcp.experimental.events.event_type import EventBatch, EventQuery, EventType, NoArguments, Occurrence, Subscriber
3536
from mcp.experimental.events.types import (
3637
EXTENSION_ID,
@@ -117,6 +118,7 @@ def __init__(
117118
self._buffer_max_age = buffer_max_age
118119
self._event_types: dict[str, EventType[Any, Any]] = {}
119120
self._leases: dict[str, anyio.CancelScope] = {}
121+
self._lease_setup = KeyedLocks()
120122
self._task_group: TaskGroup | None = None
121123
self._subscriptions: WebhookSubscriptions | None = None
122124

@@ -221,20 +223,17 @@ def _resolve(self, name: str, arguments: dict[str, Any] | None) -> tuple[EventTy
221223
async def _renew_lease(self, event_type: EventType[Any, Any], arguments: BaseModel, principal: str | None) -> None:
222224
key = json.dumps([principal, event_type.name, arguments.model_dump(mode="json")], sort_keys=True)
223225
deadline = anyio.current_time() + self._poll_lease.total_seconds()
224-
if (lease := self._leases.get(key)) is not None:
225-
lease.deadline = deadline
226-
return
227226
assert self._task_group is not None, "Events runs inside the server's lifespan"
228227
subscription_id = f"sub_{hashlib.sha256(key.encode()).hexdigest()[:16]}"
229228
subscriber = Subscriber(id=subscription_id, arguments=arguments, principal=principal, mode="poll")
230-
lease = self._leases[key] = anyio.CancelScope(deadline=deadline)
231-
try:
229+
async with self._lease_setup(key):
230+
if (lease := self._leases.get(key)) is not None:
231+
lease.deadline = deadline
232+
return
232233
for hook in event_type.subscribe_hooks:
233234
await hook(subscriber)
234-
except BaseException:
235-
del self._leases[key]
236-
raise
237-
self._task_group.start_soon(self._expire_lease, key, lease, event_type, subscriber)
235+
lease = self._leases[key] = anyio.CancelScope(deadline=deadline)
236+
self._task_group.start_soon(self._expire_lease, key, lease, event_type, subscriber)
238237

239238
async def _expire_lease(
240239
self, key: str, lease: anyio.CancelScope, event_type: EventType[Any, Any], subscriber: Subscriber[Any]

‎src/mcp/experimental/events/webhooks.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pydantic import BaseModel
1919

2020
from mcp.experimental.events._callbacks import CallbackFailure, Callbacks, NonPublicAddress
21+
from mcp.experimental.events._locks import KeyedLocks
2122
from mcp.experimental.events.event_type import EventBatch, EventQuery, EventType, Subscriber
2223
from mcp.experimental.events.signing import decode_secret
2324
from mcp.experimental.events.types import (
@@ -103,7 +104,8 @@ def __init__(
103104
self._callbacks = Callbacks(client, config.trusted_origins)
104105
self._task_group = task_group
105106
self._subscriptions: dict[str, _Subscription] = {}
106-
self._verified: set[tuple[str, str]] = set()
107+
self._verified: set[tuple[str | None, str]] = set()
108+
self._setup = KeyedLocks()
107109

108110
async def subscribe(
109111
self, event_type: EventType[Any, Any], arguments: BaseModel, principal: str, params: SubscribeEventsParams
@@ -117,8 +119,26 @@ async def subscribe(
117119
key = json.dumps([principal, url, event_type.name, params.arguments or {}], sort_keys=True)
118120
subscription_id = f"sub_{hashlib.sha256(key.encode()).hexdigest()[:16]}"
119121
ttl = self._grant(params)
120-
if (existing := self._subscriptions.get(key)) is not None:
121-
return self._refresh(existing, secret, ttl)
122+
async with self._setup(key):
123+
if (existing := self._subscriptions.get(key)) is not None:
124+
return self._refresh(existing, secret, ttl)
125+
try:
126+
return await self._create(event_type, arguments, principal, params, key, subscription_id, ttl)
127+
except BaseException:
128+
self._forget_unused_verification(principal, url)
129+
raise
130+
131+
async def _create(
132+
self,
133+
event_type: EventType[Any, Any],
134+
arguments: BaseModel,
135+
principal: str,
136+
params: SubscribeEventsParams,
137+
key: str,
138+
subscription_id: str,
139+
ttl: timedelta,
140+
) -> SubscribeEventsResult:
141+
url, secret = params.delivery.url, params.delivery.secret
122142
if (principal, url) not in self._verified:
123143
try:
124144
await self._callbacks.verify(url, subscription_id, secret)
@@ -138,8 +158,8 @@ async def subscribe(
138158
url=url,
139159
secret=secret,
140160
expires_at=datetime.now(UTC) + ttl,
141-
# Not yet delivered, so only the supplied cursor is safe to persist; after a gap it is stale.
142-
cursor=params.cursor if batch.events and not batch.truncated else batch.cursor,
161+
# The batch is not delivered yet, so only the supplied cursor is safe to persist.
162+
cursor=params.cursor if batch.events else batch.cursor,
143163
)
144164
subscription.scope.deadline = anyio.current_time() + ttl.total_seconds()
145165
for hook in event_type.subscribe_hooks:
@@ -160,6 +180,11 @@ def unsubscribe(self, event_type: EventType[Any, Any], principal: str, params: U
160180
raise events_error(NOT_FOUND, kind="subscription")
161181
subscription.scope.cancel()
162182

183+
def _forget_unused_verification(self, principal: str | None, url: str) -> None:
184+
# The spec scopes verification to the subscriptions it covers, so the cache cannot outgrow them.
185+
if not any(s.subscriber.principal == principal and s.url == url for s in self._subscriptions.values()):
186+
self._verified.discard((principal, url))
187+
163188
def _grant(self, params: SubscribeEventsParams) -> timedelta:
164189
# In-memory subscriptions cannot honour "no expiry", so an omitted or null ttlMs gets the cap.
165190
if params.ttl_ms is None:
@@ -184,6 +209,8 @@ async def _run(self, subscription: _Subscription, batch: EventBatch[Any]) -> Non
184209
try:
185210
with subscription.scope:
186211
while True:
212+
# shortcut: events go out one at a time, so a failing event delays later ones by its
213+
# retries, where the spec retries each independently; needs per-event acks behind the cursor.
187214
for index, occurrence in enumerate(batch.events):
188215
last = index == len(batch.events) - 1
189216
body = event_type.wire(occurrence, batch.cursor if last else subscription.cursor)
@@ -195,6 +222,7 @@ async def _run(self, subscription: _Subscription, batch: EventBatch[Any]) -> Non
195222
finally:
196223
if self._subscriptions.get(subscription.key) is subscription:
197224
del self._subscriptions[subscription.key]
225+
self._forget_unused_verification(subscriber.principal, subscription.url)
198226
for hook in event_type.unsubscribe_hooks:
199227
await hook(subscriber)
200228

‎tests/experimental/events/test_client.py‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ class Ping(BaseModel):
3535

3636

3737
async def test_watch_yields_batches_with_events_and_resumes_from_the_latest_cursor() -> None:
38-
"""`watch` polls in a loop, drains `hasMore` batches at once, skips empty polls, and yields
39-
batches that carry events or a gap (SDK-defined loop over spec-mandated poll semantics)."""
38+
"""`watch` polls in a loop and drains `hasMore` batches at once (SDK-defined loop over
39+
spec-mandated poll semantics)."""
4040
events = Events(poll_interval=timedelta(0), max_events=2)
4141
pings = events.event("ping", arguments=NoArguments, payload=Ping)
4242
mcp = MCPServer("pinger", extensions=[events])
@@ -73,9 +73,10 @@ async def test_watch_from_a_saved_cursor_yields_the_gap_when_the_cursor_is_unkno
7373
assert (batch.events, batch.truncated) == ([], True)
7474

7575

76-
async def test_watch_waits_at_least_the_clients_floor_between_polls() -> None:
77-
"""The client's `min_poll_interval` overrides a server asking for a tighter loop (spec-mandated floor;
78-
a time-based feature, so the test lets one floor elapse)."""
76+
async def test_watch_yields_empty_batches_and_waits_at_least_the_clients_floor_between_polls() -> None:
77+
"""Empty batches are yielded because they still advance the cursor (spec-mandated quiet-period
78+
advancement), and the client's `min_poll_interval` overrides a server asking for a tighter loop
79+
(spec-mandated floor; a time-based feature, so the test lets the floor elapse)."""
7980
polls: list[str | None] = []
8081
events = Events(poll_interval=timedelta(0))
8182
pings = events.event("ping", arguments=NoArguments, payload=Ping)
@@ -84,19 +85,24 @@ async def test_watch_waits_at_least_the_clients_floor_between_polls() -> None:
8485
async def check(query: EventQuery[NoArguments]) -> EventBatch[Ping]:
8586
polls.append(query.cursor)
8687
if len(polls) < 3:
87-
return EventBatch(cursor="c")
88-
return EventBatch(cursor="c", events=[Occurrence(Ping(n=len(polls)))])
88+
return EventBatch(cursor=f"c{len(polls)}")
89+
return EventBatch(cursor=f"c{len(polls)}", events=[Occurrence(Ping(n=len(polls)))])
8990

9091
mcp = MCPServer("pinger", extensions=[events])
9192

9293
async with Client(mcp, extensions=[ClientEvents(min_poll_interval=timedelta(milliseconds=20))]) as client:
9394
watch = client.extension(ClientEvents).watch("ping")
95+
started = anyio.current_time()
9496
with anyio.fail_after(5):
95-
batch = await anext(watch)
97+
quiet = await anext(watch)
98+
busy = await anext(watch)
9699
await watch.aclose()
100+
elapsed = anyio.current_time() - started
97101

98-
assert polls == [None, "c", "c"]
99-
assert [e.data for e in batch.events] == [{"n": 3}]
102+
assert polls == [None, "c1", "c2"]
103+
assert (quiet.events, quiet.cursor) == ([], "c2")
104+
assert [e.data for e in busy.events] == [{"n": 3}]
105+
assert elapsed >= 0.02
100106

101107

102108
class _OtherEventsServer(Extension):
@@ -127,7 +133,8 @@ async def _subscribe(
127133
) -> SubscribeEventsResult:
128134
self.subscribes.append(params)
129135
if len(self.subscribes) == 1:
130-
return SubscribeEventsResult(id="sub-1", refresh_before=datetime.now(UTC) + timedelta(milliseconds=30))
136+
refresh_before = datetime.now(UTC) + timedelta(milliseconds=30)
137+
return SubscribeEventsResult(id="sub-1", refresh_before=refresh_before, cursor="c1")
131138
self.refreshed.set()
132139
return SubscribeEventsResult(id="sub-1", refresh_before=None)
133140

@@ -149,8 +156,9 @@ async def test_list_follows_pagination_to_the_last_page() -> None:
149156

150157

151158
async def test_subscribe_refreshes_before_the_subscription_expires_and_unsubscribes_on_exit() -> None:
152-
"""`subscribe` resends the subscription before `refreshBefore`, stops once the server grants no
153-
expiry, and unsubscribes when the block exits (spec-mandated refresh; a time-based feature)."""
159+
"""`subscribe` resends the subscription with the latest cursor before `refreshBefore`, stops once
160+
the server grants no expiry, and unsubscribes when the block exits (spec-mandated refresh; a
161+
time-based feature)."""
154162
server = _OtherEventsServer()
155163
mcp = MCPServer("other", extensions=[server])
156164
secret = "whsec_" + "a" * 43 + "="
@@ -164,5 +172,5 @@ async def test_subscribe_refreshes_before_the_subscription_expires_and_unsubscri
164172
await server.unsubscribed.wait()
165173

166174
first, refresh = server.subscribes
167-
assert (first.cursor, refresh.cursor) == ("c0", None)
175+
assert (first.cursor, refresh.cursor) == ("c0", "c1")
168176
assert refresh.delivery == first.delivery

0 commit comments

Comments
 (0)