Skip to content

client

The client side of MCP Events (SEP-3415): Events, a Client extension.

async with Client(url, extensions=[Events()]) as client:
    events = client.extension(Events)
    async for batch in events.watch("incident.created", {"service": "billing"}, cursor=load_cursor()):
        for event in batch.events:
            handle(event)
        save_cursor(batch.cursor)

Events

Bases: ClientExtension

Register with Client(extensions=[Events()]), then use client.extension(Events).

Parameters:

Name Type Description Default
min_poll_interval timedelta

The shortest wait between polls, whatever the server asks for, so a misbehaving server cannot drive a tight loop.

timedelta(seconds=1)
Source code in src/mcp/experimental/events/client.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Events(ClientExtension):
    """Register with `Client(extensions=[Events()])`, then use `client.extension(Events)`.

    Args:
        min_poll_interval: The shortest wait between polls, whatever the server asks for,
            so a misbehaving server cannot drive a tight loop.
    """

    identifier = EXTENSION_ID

    def __init__(self, *, min_poll_interval: timedelta = timedelta(seconds=1)) -> None:
        self._min_poll_interval = min_poll_interval

    def bind(self, session: ClientSession) -> EventsClient:
        """Return the events verbs bound to `session`."""
        return EventsClient(session, self._min_poll_interval)

bind

bind(session: ClientSession) -> EventsClient

Return the events verbs bound to session.

Source code in src/mcp/experimental/events/client.py
59
60
61
def bind(self, session: ClientSession) -> EventsClient:
    """Return the events verbs bound to `session`."""
    return EventsClient(session, self._min_poll_interval)

EventsClient

The events verbs bound to one connected session. Obtain it from client.extension(Events).

Source code in src/mcp/experimental/events/client.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 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
class EventsClient:
    """The events verbs bound to one connected session. Obtain it from `client.extension(Events)`."""

    def __init__(self, session: ClientSession, min_poll_interval: timedelta) -> None:
        self._session = session
        self._min_poll_interval = min_poll_interval

    async def list(self) -> list[EventDescriptor]:
        """Return every event type the server offers, following pagination."""
        descriptors: list[EventDescriptor] = []
        cursor: str | None = None
        while True:
            request = ListEventsRequest(params=ListEventsParams(cursor=cursor))
            result = await self._session.send_request(request, ListEventsResult)
            descriptors.extend(result.events)
            if (cursor := result.next_cursor) is None:
                return descriptors

    async def poll(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
        *,
        cursor: str | None = None,
        max_age: timedelta | None = None,
        max_events: int | None = None,
    ) -> PollEventsResult:
        """Read the events after `cursor` once. A `None` cursor returns no events, only the current position."""
        params = PollEventsParams(
            name=name,
            arguments=arguments,
            cursor=cursor,
            max_age_ms=int(max_age.total_seconds() * 1000) if max_age is not None else None,
            max_events=max_events,
        )
        return await self._session.send_request(PollEventsRequest(params=params), PollEventsResult)

    async def watch(
        self, name: str, arguments: dict[str, Any] | None = None, *, cursor: str | None = None
    ) -> AsyncGenerator[PollEventsResult, None]:
        """Poll forever, yielding every batch, including empty ones.

        Persist `batch.cursor` after handling a batch, and pass it back after a restart
        to resume: delivery is at-least-once, so deduplicate on `event_id`. Empty batches
        still move the cursor, which keeps it inside the upstream's retention window.
        """
        if cursor is None:
            cursor = (await self.poll(name, arguments)).cursor
        while True:
            batch = await self.poll(name, arguments, cursor=cursor)
            cursor = batch.cursor
            yield batch
            if not batch.has_more:
                wait = max(timedelta(milliseconds=batch.next_poll_ms), self._min_poll_interval)
                await anyio.sleep(wait.total_seconds())

    @asynccontextmanager
    async def subscribe(
        self,
        name: str,
        arguments: dict[str, Any] | None = None,
        *,
        url: str,
        secret: str,
        cursor: str | None = None,
        ttl: timedelta | None = None,
    ) -> AsyncIterator[SubscribeEventsResult]:
        """Hold a webhook subscription: events are POSTed to `url`, signed with `secret`.

        The subscription is refreshed in the background before it expires, and removed on exit.
        Receive the deliveries with `mcp.experimental.events.receiver.WebhookReceiver`.

        Raises:
            MCPError: The server refused the subscription, e.g. because `url` failed verification.
        """
        params = SubscribeEventsParams(
            name=name,
            arguments=arguments,
            delivery=WebhookDelivery(url=url, secret=secret),
            cursor=cursor,
            ttl_ms=int(ttl.total_seconds() * 1000) if ttl is not None else None,
        )
        result = await self._session.send_request(SubscribeEventsRequest(params=params), SubscribeEventsResult)
        async with anyio.create_task_group() as task_group:
            task_group.start_soon(self._refresh, params, result)
            try:
                yield result
            finally:
                task_group.cancel_scope.cancel()
                unsubscribe = UnsubscribeEventsParams(name=name, arguments=arguments, delivery=WebhookTarget(url=url))
                with anyio.CancelScope(shield=True):
                    request = UnsubscribeEventsRequest(params=unsubscribe)
                    await self._session.send_request(request, UnsubscribeEventsResult)

    async def _refresh(self, params: SubscribeEventsParams, result: SubscribeEventsResult) -> None:
        while result.refresh_before is not None:
            # Refresh at two thirds of the remaining lifetime, leaving room for a failed attempt.
            await anyio.sleep((result.refresh_before - datetime.now(UTC)).total_seconds() * 2 / 3)
            # The latest safe cursor is a no-op for a live subscription, and the replay point if the server lost it.
            request = SubscribeEventsRequest(params=params.model_copy(update={"cursor": result.cursor}))
            result = await self._session.send_request(request, SubscribeEventsResult)

list async

list() -> list[EventDescriptor]

Return every event type the server offers, following pagination.

Source code in src/mcp/experimental/events/client.py
71
72
73
74
75
76
77
78
79
80
async def list(self) -> list[EventDescriptor]:
    """Return every event type the server offers, following pagination."""
    descriptors: list[EventDescriptor] = []
    cursor: str | None = None
    while True:
        request = ListEventsRequest(params=ListEventsParams(cursor=cursor))
        result = await self._session.send_request(request, ListEventsResult)
        descriptors.extend(result.events)
        if (cursor := result.next_cursor) is None:
            return descriptors

poll async

poll(
    name: str,
    arguments: dict[str, Any] | None = None,
    *,
    cursor: str | None = None,
    max_age: timedelta | None = None,
    max_events: int | None = None
) -> PollEventsResult

Read the events after cursor once. A None cursor returns no events, only the current position.

Source code in src/mcp/experimental/events/client.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
async def poll(
    self,
    name: str,
    arguments: dict[str, Any] | None = None,
    *,
    cursor: str | None = None,
    max_age: timedelta | None = None,
    max_events: int | None = None,
) -> PollEventsResult:
    """Read the events after `cursor` once. A `None` cursor returns no events, only the current position."""
    params = PollEventsParams(
        name=name,
        arguments=arguments,
        cursor=cursor,
        max_age_ms=int(max_age.total_seconds() * 1000) if max_age is not None else None,
        max_events=max_events,
    )
    return await self._session.send_request(PollEventsRequest(params=params), PollEventsResult)

watch async

watch(
    name: str,
    arguments: dict[str, Any] | None = None,
    *,
    cursor: str | None = None
) -> AsyncGenerator[PollEventsResult, None]

Poll forever, yielding every batch, including empty ones.

Persist batch.cursor after handling a batch, and pass it back after a restart to resume: delivery is at-least-once, so deduplicate on event_id. Empty batches still move the cursor, which keeps it inside the upstream's retention window.

Source code in src/mcp/experimental/events/client.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
async def watch(
    self, name: str, arguments: dict[str, Any] | None = None, *, cursor: str | None = None
) -> AsyncGenerator[PollEventsResult, None]:
    """Poll forever, yielding every batch, including empty ones.

    Persist `batch.cursor` after handling a batch, and pass it back after a restart
    to resume: delivery is at-least-once, so deduplicate on `event_id`. Empty batches
    still move the cursor, which keeps it inside the upstream's retention window.
    """
    if cursor is None:
        cursor = (await self.poll(name, arguments)).cursor
    while True:
        batch = await self.poll(name, arguments, cursor=cursor)
        cursor = batch.cursor
        yield batch
        if not batch.has_more:
            wait = max(timedelta(milliseconds=batch.next_poll_ms), self._min_poll_interval)
            await anyio.sleep(wait.total_seconds())

subscribe async

subscribe(
    name: str,
    arguments: dict[str, Any] | None = None,
    *,
    url: str,
    secret: str,
    cursor: str | None = None,
    ttl: timedelta | None = None
) -> AsyncIterator[SubscribeEventsResult]

Hold a webhook subscription: events are POSTed to url, signed with secret.

The subscription is refreshed in the background before it expires, and removed on exit. Receive the deliveries with mcp.experimental.events.receiver.WebhookReceiver.

Raises:

Type Description
MCPError

The server refused the subscription, e.g. because url failed verification.

Source code in src/mcp/experimental/events/client.py
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
@asynccontextmanager
async def subscribe(
    self,
    name: str,
    arguments: dict[str, Any] | None = None,
    *,
    url: str,
    secret: str,
    cursor: str | None = None,
    ttl: timedelta | None = None,
) -> AsyncIterator[SubscribeEventsResult]:
    """Hold a webhook subscription: events are POSTed to `url`, signed with `secret`.

    The subscription is refreshed in the background before it expires, and removed on exit.
    Receive the deliveries with `mcp.experimental.events.receiver.WebhookReceiver`.

    Raises:
        MCPError: The server refused the subscription, e.g. because `url` failed verification.
    """
    params = SubscribeEventsParams(
        name=name,
        arguments=arguments,
        delivery=WebhookDelivery(url=url, secret=secret),
        cursor=cursor,
        ttl_ms=int(ttl.total_seconds() * 1000) if ttl is not None else None,
    )
    result = await self._session.send_request(SubscribeEventsRequest(params=params), SubscribeEventsResult)
    async with anyio.create_task_group() as task_group:
        task_group.start_soon(self._refresh, params, result)
        try:
            yield result
        finally:
            task_group.cancel_scope.cancel()
            unsubscribe = UnsubscribeEventsParams(name=name, arguments=arguments, delivery=WebhookTarget(url=url))
            with anyio.CancelScope(shield=True):
                request = UnsubscribeEventsRequest(params=unsubscribe)
                await self._session.send_request(request, UnsubscribeEventsResult)