|
| 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. |
0 commit comments