Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 299 additions & 0 deletions docs/advanced/events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Events

[SEP-3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415) lets a server
tell clients that something happened: a new incident, an email, a merged PR. A client subscribes
to an **event type** with arguments, such as `incident.created` for the `billing` service. It
then receives each matching **event** by polling or at a webhook.

The SDK ships this as the `Events` extension (`io.modelcontextprotocol/events`), with a server
side and a client side. If [Extensions](extensions.md) are new to you, skim that page first.

!!! warning "Experimental: the wire format may change"
SEP-3415 is a draft. Events lives under `mcp.experimental.events`, and its API and wire
format may change in any release until the SEP is accepted. Push delivery (`events/stream`)
is not implemented yet.

## Offering events

```python title="server.py"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be a tutorial file instead

from pydantic import BaseModel

from mcp.experimental.events.server import Events
from mcp.server.mcpserver import MCPServer


class IncidentFilter(BaseModel):
service: str


class Incident(BaseModel):
service: str
title: str


events = Events()
incidents = events.event(
"incident.created",
arguments=IncidentFilter,
payload=Incident,
description="A new incident was opened",
)


@incidents.match
def same_service(arguments: IncidentFilter, incident: Incident) -> bool:
return arguments.service == incident.service


mcp = MCPServer("pager", extensions=[events])


@mcp.tool()
def open_incident(service: str, title: str) -> str:
"""Open an incident."""
incidents.emit(Incident(service=service, title=title))
return "opened"
```

* `events.event(...)` declares an event type. `arguments` is the model subscribers pass, and
`payload` is the model each event carries. `events/list` publishes both as JSON Schema, and
arguments are validated before your code sees them.
* `emit` records an event. The server keeps the most recent events in a bounded buffer, so a
client that polls every few seconds misses nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The default emit buffer holds only 1,000 events and 10 minutes, so a burst can evict unread events even when polling every few seconds. Qualify this guarantee and tell readers to handle batch.truncated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/advanced/events.md, line 62:

<comment>The default emit buffer holds only 1,000 events and 10 minutes, so a burst can evict unread events even when polling every few seconds. Qualify this guarantee and tell readers to handle `batch.truncated`.</comment>

<file context>
@@ -0,0 +1,290 @@
+  `payload` is the model each event carries. `events/list` publishes both as JSON Schema, and
+  arguments are validated before your code sees them.
+* `emit` records an event. The server keeps the most recent events in a bounded buffer, so a
+  client that polls every few seconds misses nothing.
+* `match` decides which subscribers receive an emitted event. Without it, everyone does.
+  Use `transform` the same way to reshape an event per subscriber, for example to redact fields.
</file context>
Suggested change
client that polls every few seconds misses nothing.
client may miss events if the buffer evicts them before a poll; check `batch.truncated` for gaps.

* `match` decides which subscribers receive an emitted event. Without it, everyone does.
Use `transform` the same way to reshape an event per subscriber, for example to redact fields.

Use `NoArguments` for an event type that takes no arguments.

### Reading from an upstream with a check function

When the upstream can answer "what happened since this cursor", register a check function
instead of calling `emit`:

```python title="server.py"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here and all other code snippets

from datetime import timedelta

from pydantic import BaseModel

from mcp.experimental.events.server import EventBatch, EventQuery, Events, Occurrence
from mcp.server.mcpserver import MCPServer


class Repository(BaseModel):
name: str


class Push(BaseModel):
sha: str


events = Events()
pushes = events.event("repo.pushed", arguments=Repository, payload=Push)


@pushes.check
async def check(query: EventQuery[Repository]) -> EventBatch[Push]:
commits, position = await upstream_commits(query.arguments.name, after=query.cursor)
return EventBatch(
cursor=position,
events=[Occurrence(Push(sha=sha), id=sha) for sha in commits],
next_poll=timedelta(seconds=30),
)


async def upstream_commits(repository: str, after: str | None) -> tuple[list[str], str]:
return [], "head"


mcp = MCPServer("git", extensions=[events])
```

The cursor is yours: an offset, a history id, a timestamp. A `None` cursor means "start from
now", so the SDK returns your cursor with no events. Set the occurrence `id` to the upstream's
own identifier, because clients deduplicate on it.

Return `truncated=True` when the events start later than the cursor, for example because it
fell out of the upstream's retention window. Clients use it to know they missed something.

### Subscription hooks

```python title="server.py"
from pydantic import BaseModel

from mcp.experimental.events.server import Events, Subscriber
from mcp.experimental.events.types import FORBIDDEN, events_error
from mcp.server.mcpserver import MCPServer


class IncidentFilter(BaseModel):
service: str


class Incident(BaseModel):
service: str
title: str


events = Events()
incidents = events.event("incident.created", arguments=IncidentFilter, payload=Incident)
watched: dict[str, int] = {}


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


@incidents.on_unsubscribe
async def stop_watching(subscriber: Subscriber[IncidentFilter]) -> None:
watched[subscriber.arguments.service] -= 1


mcp = MCPServer("pager", extensions=[events])
```

Hooks let you set up and tear down upstream work per subscription. `on_subscribe` is also where
you check access: the subscriber carries the caller's `principal`, and raising an `MCPError`
rejects the poll or subscription with that error.
Comment on lines +158 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Server authors who follow the new docs get an access check that runs once, so a principal whose access is later revoked keeps receiving events. docs/advanced/events.md:158 says on_subscribe is where to check access. A webhook refresh returns through _refresh (webhooks.py:124) and a renewed poll lease returns at server.py:232, both without running the hook. Fix: re-check access on every refresh and lease renewal, or give emit-fed types a per-read principal check and say in the docs that the hook is subscribe-time only. match gets no principal, so emit-fed types have no other gate. [also at: src/mcp/experimental/events/server.py:232 - A caller whose access is revoked keeps receiving events for as long as it keeps polling or refreshing. The docs now name on_subscribe as the place to check access (docs/advanced/events.md:158-160), but the hook runs only when a lease or subscription is created.]

Why this was flagged

A server registers an emit-fed event type and checks subscriber.principal in on_subscribe, as docs/advanced/events.md:142-145 and 158-160 show. A poll client keeps polling: _renew_lease finds the lease, sets lease.deadline = deadline and returns (server.py:230-232), so the hook ran only on the first poll. A webhook client refreshes before refresh_before: subscribe returns self._refresh(existing, secret, ttl) (webhooks.py:123-124), which extends the deadline and runs no hook. The operator then revokes that principal's access. Polls and deliveries continue for as long as the client keeps polling or refreshing; max_ttl does not bound this because each refresh re-arms it (webhooks.py:198-199). _select calls self._match(arguments, payload) with no principal (event_type.py:182-185), so an emit-fed type has no other place to refuse. The base branch has no Events extension, and before this push the docs did not present the hook as the access check.

Verification: Triggers when a server author puts a revocable access check in on_subscribe of an emit-fed event type, as the docs direct, and that access is later revoked while the caller's token stays valid. The hooks at server.py:233-234 run only when no lease exists. _refresh (webhooks.py:194-205) only rotates the secret and extends expires_at and scope.deadline; no hook runs.

Finding ID: f-4d2fed41


A poll client never says
goodbye, so the SDK leases poll subscriptions: `on_subscribe` runs on the first poll for a
principal, event type, and arguments, and `on_unsubscribe` runs once no poll renewed the lease
for `poll_lease` (three poll intervals by default).

## Webhook delivery

```python
from mcp.experimental.events.server import Events, Webhooks

events = Events(webhooks=Webhooks())
```

Passing `Webhooks` offers webhook delivery for every event type. The client gives a callback URL
and a secret, and the server POSTs each event there, signed with
[Standard Webhooks](https://www.standardwebhooks.com/). It retries failed deliveries, and reports
failures to the client when it refreshes the subscription.

!!! warning "Webhooks need an authenticated principal"
A subscription is identified by who made it, the callback URL, the event type, and the
arguments. Without a principal anyone could cancel or hijack another caller's subscription,
so the server refuses webhook calls with `Forbidden` unless the caller is authenticated.
By default the principal comes from the request's OAuth access token. Pass
`Events(principal=...)` to derive it another way.

Callback URLs come from clients, so the server treats them as hostile:

* Only `https` URLs are accepted.
* The host is resolved before every request, and refused unless every address is public.
The server connects to the address it checked, so a DNS answer cannot change in between.
* Redirects are never followed.
* Before the first delivery, the endpoint must echo a random challenge, so a server cannot be
used to POST at a URL that never asked for events.

For a receiver on your machine during development, allow its origin explicitly:

```python
from mcp.experimental.events.server import Events, Webhooks

events = Events(webhooks=Webhooks(trusted_origins=["http://127.0.0.1:9000"]))
```

Subscriptions live in memory. The server caps each one at `max_ttl` (one hour by default), and
clients refresh before it lapses. After a restart, clients subscribe again.

## Consuming events

### Polling

```python title="client.py"
import anyio

from mcp.client import Client
from mcp.experimental.events.client import Events


async def main() -> None:
async with Client("http://localhost:8000/mcp", extensions=[Events()]) as client:
events = client.extension(Events)
async for batch in events.watch("incident.created", {"service": "billing"}):
for event in batch.events:
print(event.event_id, event.data)


anyio.run(main)
```

`watch` polls in a loop. It waits as long as the server asks (`nextPollMs`), never less than
`Events(min_poll_interval=...)`, and polls again at once while the server has more. It yields
every batch, including empty ones, because an empty batch still moves the cursor. A batch with
`batch.truncated` set means events were skipped.

To resume after a restart, save `batch.cursor` once you handled a batch, and pass it back with
`watch(..., cursor=saved)`. Delivery is at least once, so deduplicate on `event_id`.

For a single read, use `events.poll(...)`.

### Webhooks

```python title="client.py"
import base64
import secrets

import anyio
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount

from mcp.client import Client
from mcp.experimental.events.client import Events
from mcp.experimental.events.receiver import Delivery, WebhookReceiver

SECRET = "whsec_" + base64.b64encode(secrets.token_bytes(32)).decode()


async def handle(delivery: Delivery) -> None:
print(delivery)


receiver = WebhookReceiver(SECRET, handle)
app = Starlette(routes=[Mount("/hooks/events", receiver)])


async def main() -> None:
config = uvicorn.Config(app, port=9000)
async with anyio.create_task_group() as task_group:
task_group.start_soon(uvicorn.Server(config).serve)
async with Client("https://pager.example.com/mcp", extensions=[Events()]) as client:
events = client.extension(Events)
async with events.subscribe(
"incident.created",
{"service": "billing"},
url="https://hooks.example.com/hooks/events",
secret=SECRET,
):
await anyio.sleep_forever()


anyio.run(main)
```

`subscribe` holds the subscription for the duration of the block. It refreshes it before it
expires, passing the latest cursor so a server that lost the subscription replays from there,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This promises replay after a lost subscription, but emit event history is process-local: after a restart, the old cursor produces an empty truncated batch. Qualify replay as dependent on the event source retaining that cursor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/advanced/events.md, line 284:

<comment>This promises replay after a lost subscription, but `emit` event history is process-local: after a restart, the old cursor produces an empty truncated batch. Qualify replay as dependent on the event source retaining that cursor.</comment>

<file context>
@@ -273,7 +281,8 @@ anyio.run(main)
 
 `subscribe` holds the subscription for the duration of the block. It refreshes it before it
-expires, and unsubscribes on exit.
+expires, passing the latest cursor so a server that lost the subscription replays from there,
+and unsubscribes on exit.
 
</file context>
Suggested change
expires, passing the latest cursor so a server that lost the subscription replays from there,
expires, passing the latest cursor so a server can replay from it when its event source still retains that cursor,

and unsubscribes on exit.

`WebhookReceiver` is an ASGI app for the callback URL. It answers the verification challenge,
rejects requests that are unsigned, signed with another secret, or more than five minutes old,
and passes each delivery to your handler:

| Delivery | Meaning |
| --- | --- |
| `EventOccurrence` | An event. `cursor` is the subscription's position after it. |
| `WebhookGap` | Events were skipped. Reconcile if you need every event. |
| `WebhookTerminated` | The server ended the subscription, for example because access was revoked. |

The receiver drops repeated `event_id`s, since servers retry. A secret is `whsec_` followed by

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Deduplication covers only the last 1,000 IDs in memory; eviction or a receiver restart lets retried deliveries reach the handler again. Document the limit and require durable handler-side idempotency for duplicate-sensitive work.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/advanced/events.md, line 288:

<comment>Deduplication covers only the last 1,000 IDs in memory; eviction or a receiver restart lets retried deliveries reach the handler again. Document the limit and require durable handler-side idempotency for duplicate-sensitive work.</comment>

<file context>
@@ -0,0 +1,290 @@
+| `WebhookGap` | Events were skipped. Reconcile if you need every event. |
+| `WebhookTerminated` | The server ended the subscription, for example because access was revoked. |
+
+The receiver drops repeated `event_id`s, since servers retry. A secret is `whsec_` followed by
+the base64 of 24 to 64 random bytes, as `SECRET` shows. Store it, since the receiver needs the
+same secret after a restart.
</file context>

the base64 of 24 to 64 random bytes, as `SECRET` shows. Store it, since the receiver needs the
same secret after a restart.
37 changes: 37 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,43 @@ The client is its own program, and it carries both halves of the client story:
so the client declares them for itself. A published extension would ship them in
a package that both sides import.

### Running background work

Override `lifespan()` when the extension needs tasks that outlive a request, such as
delivery loops or lease timers:

```python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import anyio

from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer


class Heartbeat(Extension):
identifier = "com.example/heartbeat"

@asynccontextmanager
async def lifespan(self) -> AsyncIterator[None]:
async with anyio.create_task_group() as task_group:
task_group.start_soon(self._beat)
yield
task_group.cancel_scope.cancel()

async def _beat(self) -> None:
while True:
await anyio.sleep(30)


mcp = MCPServer("demo", extensions=[Heartbeat()])
```

`MCPServer` enters every extension's `lifespan()` before its own lifespan and exits them
after it, so your tasks run whenever the server does. Cancel them after the `yield`, or
shutdown waits for them.

### Intercepting `tools/call`

The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ nav:
- Extensions: advanced/extensions.md
- MCP Apps: advanced/apps.md
- Skills: advanced/skills.md
- Events: advanced/events.md
- Troubleshooting: troubleshooting.md
- Translations: translations.md
- Migration Guide: migration.md
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Experimental extensions whose specifications are still drafts; their APIs may change in any release."""

__all__: list[str] = []
6 changes: 6 additions & 0 deletions src/mcp/experimental/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""MCP Events (`io.modelcontextprotocol/events`, SEP-3415), server and client sides.

See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415.
"""

__all__: list[str] = []
Loading
Loading