Skip to content

Commit ad16a1c

Browse files
committed
Add experimental MCP Events extension with poll and webhook delivery
1 parent 77001c9 commit ad16a1c

23 files changed

Lines changed: 3139 additions & 1 deletion

‎docs/advanced/events.md‎

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# Events
2+
3+
[SEP-3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415) lets a server
4+
tell clients that something happened: a new incident, an email, a merged PR. A client subscribes
5+
to an **event type** with arguments, such as `incident.created` for the `billing` service. It
6+
then receives each matching **event** by polling or at a webhook.
7+
8+
The SDK ships this as the `Events` extension (`io.modelcontextprotocol/events`), with a server
9+
side and a client side. If [Extensions](extensions.md) are new to you, skim that page first.
10+
11+
!!! warning "Experimental: the wire format may change"
12+
SEP-3415 is a draft. Events lives under `mcp.experimental.events`, and its API and wire
13+
format may change in any release until the SEP is accepted. Push delivery (`events/stream`)
14+
is not implemented yet.
15+
16+
## Offering events
17+
18+
```python title="server.py"
19+
from pydantic import BaseModel
20+
21+
from mcp.experimental.events.server import Events
22+
from mcp.server.mcpserver import MCPServer
23+
24+
25+
class IncidentFilter(BaseModel):
26+
service: str
27+
28+
29+
class Incident(BaseModel):
30+
service: str
31+
title: str
32+
33+
34+
events = Events()
35+
incidents = events.event(
36+
"incident.created",
37+
arguments=IncidentFilter,
38+
payload=Incident,
39+
description="A new incident was opened",
40+
)
41+
42+
43+
@incidents.match
44+
def same_service(arguments: IncidentFilter, incident: Incident) -> bool:
45+
return arguments.service == incident.service
46+
47+
48+
mcp = MCPServer("pager", extensions=[events])
49+
50+
51+
@mcp.tool()
52+
def open_incident(service: str, title: str) -> str:
53+
"""Open an incident."""
54+
incidents.emit(Incident(service=service, title=title))
55+
return "opened"
56+
```
57+
58+
* `events.event(...)` declares an event type. `arguments` is the model subscribers pass, and
59+
`payload` is the model each event carries. `events/list` publishes both as JSON Schema, and
60+
arguments are validated before your code sees them.
61+
* `emit` records an event. The server keeps the most recent events in a bounded buffer, so a
62+
client that polls every few seconds misses nothing.
63+
* `match` decides which subscribers receive an emitted event. Without it, everyone does.
64+
Use `transform` the same way to reshape an event per subscriber, for example to redact fields.
65+
66+
Use `NoArguments` for an event type that takes no arguments.
67+
68+
### Reading from an upstream with a check function
69+
70+
When the upstream can answer "what happened since this cursor", register a check function
71+
instead of calling `emit`:
72+
73+
```python title="server.py"
74+
from datetime import timedelta
75+
76+
from pydantic import BaseModel
77+
78+
from mcp.experimental.events.server import EventBatch, EventQuery, Events, Occurrence
79+
from mcp.server.mcpserver import MCPServer
80+
81+
82+
class Repository(BaseModel):
83+
name: str
84+
85+
86+
class Push(BaseModel):
87+
sha: str
88+
89+
90+
events = Events()
91+
pushes = events.event("repo.pushed", arguments=Repository, payload=Push)
92+
93+
94+
@pushes.check
95+
async def check(query: EventQuery[Repository]) -> EventBatch[Push]:
96+
commits, position = await upstream_commits(query.arguments.name, after=query.cursor)
97+
return EventBatch(
98+
cursor=position,
99+
events=[Occurrence(Push(sha=sha), id=sha) for sha in commits],
100+
next_poll=timedelta(seconds=30),
101+
)
102+
103+
104+
async def upstream_commits(repository: str, after: str | None) -> tuple[list[str], str]:
105+
return [], "head"
106+
107+
108+
mcp = MCPServer("git", extensions=[events])
109+
```
110+
111+
The cursor is yours: an offset, a history id, a timestamp. A `None` cursor means "start from
112+
now", so the SDK returns your cursor with no events. Set the occurrence `id` to the upstream's
113+
own identifier, because clients deduplicate on it.
114+
115+
Return `truncated=True` when the events start later than the cursor, for example because it
116+
fell out of the upstream's retention window. Clients use it to know they missed something.
117+
118+
### Subscription hooks
119+
120+
```python title="server.py"
121+
from pydantic import BaseModel
122+
123+
from mcp.experimental.events.server import Events, Subscriber
124+
from mcp.server.mcpserver import MCPServer
125+
126+
127+
class IncidentFilter(BaseModel):
128+
service: str
129+
130+
131+
class Incident(BaseModel):
132+
service: str
133+
title: str
134+
135+
136+
events = Events()
137+
incidents = events.event("incident.created", arguments=IncidentFilter, payload=Incident)
138+
watched: dict[str, int] = {}
139+
140+
141+
@incidents.on_subscribe
142+
async def start_watching(subscriber: Subscriber[IncidentFilter]) -> None:
143+
service = subscriber.arguments.service
144+
watched[service] = watched.get(service, 0) + 1
145+
146+
147+
@incidents.on_unsubscribe
148+
async def stop_watching(subscriber: Subscriber[IncidentFilter]) -> None:
149+
watched[subscriber.arguments.service] -= 1
150+
151+
152+
mcp = MCPServer("pager", extensions=[events])
153+
```
154+
155+
Hooks let you set up and tear down upstream work per subscription. A poll client never says
156+
goodbye, so the SDK leases poll subscriptions: `on_subscribe` runs on the first poll for a
157+
principal, event type, and arguments, and `on_unsubscribe` runs once no poll renewed the lease
158+
for `poll_lease` (three poll intervals by default).
159+
160+
## Webhook delivery
161+
162+
```python
163+
from mcp.experimental.events.server import Events, Webhooks
164+
165+
events = Events(webhooks=Webhooks())
166+
```
167+
168+
Passing `Webhooks` offers webhook delivery for every event type. The client gives a callback URL
169+
and a secret, and the server POSTs each event there, signed with
170+
[Standard Webhooks](https://www.standardwebhooks.com/). It retries failed deliveries, and reports
171+
failures to the client when it refreshes the subscription.
172+
173+
!!! warning "Webhooks need an authenticated principal"
174+
A subscription is identified by who made it, the callback URL, the event type, and the
175+
arguments. Without a principal anyone could cancel or hijack another caller's subscription,
176+
so the server refuses webhook calls with `Forbidden` unless the caller is authenticated.
177+
By default the principal comes from the request's OAuth access token. Pass
178+
`Events(principal=...)` to derive it another way.
179+
180+
Callback URLs come from clients, so the server treats them as hostile:
181+
182+
* Only `https` URLs are accepted.
183+
* The host is resolved before every request, and refused unless every address is public.
184+
The server connects to the address it checked, so a DNS answer cannot change in between.
185+
* Redirects are never followed.
186+
* Before the first delivery, the endpoint must echo a random challenge, so a server cannot be
187+
used to POST at a URL that never asked for events.
188+
189+
For a receiver on your machine during development, allow its origin explicitly:
190+
191+
```python
192+
from mcp.experimental.events.server import Events, Webhooks
193+
194+
events = Events(webhooks=Webhooks(trusted_origins=["http://127.0.0.1:9000"]))
195+
```
196+
197+
Subscriptions live in memory. The server caps each one at `max_ttl` (one hour by default), and
198+
clients refresh before it lapses. After a restart, clients subscribe again.
199+
200+
## Consuming events
201+
202+
### Polling
203+
204+
```python title="client.py"
205+
import anyio
206+
207+
from mcp.client import Client
208+
from mcp.experimental.events.client import Events
209+
210+
211+
async def main() -> None:
212+
async with Client("http://localhost:8000/mcp", extensions=[Events()]) as client:
213+
events = client.extension(Events)
214+
async for batch in events.watch("incident.created", {"service": "billing"}):
215+
for event in batch.events:
216+
print(event.event_id, event.data)
217+
218+
219+
anyio.run(main)
220+
```
221+
222+
`watch` polls in a loop. It waits as long as the server asks (`nextPollMs`), never less than
223+
`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`).
225+
226+
To resume after a restart, save `batch.cursor` once you handled a batch, and pass it back with
227+
`watch(..., cursor=saved)`. Delivery is at least once, so deduplicate on `event_id`.
228+
229+
For a single read, use `events.poll(...)`.
230+
231+
### Webhooks
232+
233+
```python title="client.py"
234+
import base64
235+
import secrets
236+
237+
import anyio
238+
import uvicorn
239+
from starlette.applications import Starlette
240+
from starlette.routing import Mount
241+
242+
from mcp.client import Client
243+
from mcp.experimental.events.client import Events
244+
from mcp.experimental.events.receiver import Delivery, WebhookReceiver
245+
246+
SECRET = "whsec_" + base64.b64encode(secrets.token_bytes(32)).decode()
247+
248+
249+
async def handle(delivery: Delivery) -> None:
250+
print(delivery)
251+
252+
253+
receiver = WebhookReceiver(SECRET, handle)
254+
app = Starlette(routes=[Mount("/hooks/events", receiver)])
255+
256+
257+
async def main() -> None:
258+
config = uvicorn.Config(app, port=9000)
259+
async with anyio.create_task_group() as task_group:
260+
task_group.start_soon(uvicorn.Server(config).serve)
261+
async with Client("https://pager.example.com/mcp", extensions=[Events()]) as client:
262+
events = client.extension(Events)
263+
async with events.subscribe(
264+
"incident.created",
265+
{"service": "billing"},
266+
url="https://hooks.example.com/hooks/events",
267+
secret=SECRET,
268+
):
269+
await anyio.sleep_forever()
270+
271+
272+
anyio.run(main)
273+
```
274+
275+
`subscribe` holds the subscription for the duration of the block. It refreshes it before it
276+
expires, and unsubscribes on exit.
277+
278+
`WebhookReceiver` is an ASGI app for the callback URL. It answers the verification challenge,
279+
rejects requests that are unsigned, signed with another secret, or more than five minutes old,
280+
and passes each delivery to your handler:
281+
282+
| Delivery | Meaning |
283+
| --- | --- |
284+
| `EventOccurrence` | An event. `cursor` is the subscription's position after it. |
285+
| `WebhookGap` | Events were skipped. Reconcile if you need every event. |
286+
| `WebhookTerminated` | The server ended the subscription, for example because access was revoked. |
287+
288+
The receiver drops repeated `event_id`s, since servers retry. A secret is `whsec_` followed by
289+
the base64 of 24 to 64 random bytes, as `SECRET` shows. Store it, since the receiver needs the
290+
same secret after a restart.

‎docs/advanced/extensions.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,43 @@ The client is its own program, and it carries both halves of the client story:
133133
so the client declares them for itself. A published extension would ship them in
134134
a package that both sides import.
135135

136+
### Running background work
137+
138+
Override `lifespan()` when the extension needs tasks that outlive a request, such as
139+
delivery loops or lease timers:
140+
141+
```python
142+
from collections.abc import AsyncIterator
143+
from contextlib import asynccontextmanager
144+
145+
import anyio
146+
147+
from mcp.server.extension import Extension
148+
from mcp.server.mcpserver import MCPServer
149+
150+
151+
class Heartbeat(Extension):
152+
identifier = "com.example/heartbeat"
153+
154+
@asynccontextmanager
155+
async def lifespan(self) -> AsyncIterator[None]:
156+
async with anyio.create_task_group() as task_group:
157+
task_group.start_soon(self._beat)
158+
yield
159+
task_group.cancel_scope.cancel()
160+
161+
async def _beat(self) -> None:
162+
while True:
163+
await anyio.sleep(30)
164+
165+
166+
mcp = MCPServer("demo", extensions=[Heartbeat()])
167+
```
168+
169+
`MCPServer` enters every extension's `lifespan()` before its own lifespan and exits them
170+
after it, so your tasks run whenever the server does. Cancel them after the `yield`, or
171+
shutdown waits for them.
172+
136173
### Intercepting `tools/call`
137174

138175
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ nav:
7070
- Extensions: advanced/extensions.md
7171
- MCP Apps: advanced/apps.md
7272
- Skills: advanced/skills.md
73+
- Events: advanced/events.md
7374
- Troubleshooting: troubleshooting.md
7475
- Translations: translations.md
7576
- Migration Guide: migration.md

‎src/mcp/experimental/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Experimental extensions whose specifications are still drafts; their APIs may change in any release."""
2+
3+
__all__: list[str] = []
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""MCP Events (`io.modelcontextprotocol/events`, SEP-3415), server and client sides.
2+
3+
See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415.
4+
"""
5+
6+
__all__: list[str] = []

0 commit comments

Comments
 (0)