Events — Discord

MCP Events extension: list, push (SSE), poll, webhook with TTL auto-refresh. Experimental.

Discord Events Example

Experimental — MCP Events, a draft extension (spec), built on experimental/ext/events. Wire format may change.

Reference server demonstrating the MCP Events spec with Discord as the event source. Built on the experimental/ext/events library.

Companion to the Telegram example — shows the events library handles structurally different payloads (Discord has nested author objects, embeds, threads, mentions vs Telegram’s flat text model).

Walkthrough

The canonical demo for the events extension. Two ways to run it.

Option A — Test mode (no Discord token needed)

All walkthrough steps run; the final live-interaction step skips with a “no token” message. Great for a quick read-through of the protocol features.

just serve    # terminal 1 — server in test mode
just demo     # terminal 2 — walkthrough

To simulate Discord activity (so you can see push/poll/webhook fanout in real time), inject events from a third terminal:

TEXT=... just inject TEXT="hello world"   # message event (cursored)
TEXT=... just inject-typing               # typing indicator (cursorless)

Option B — Real bot mode (requires DISCORD_BOT_TOKEN)

Same walkthrough plus the final live step captures real typing + message events from the Discord channel where you invited the bot.

DISCORD_BOT_TOKEN=your-token just serve   # terminal 1 — server in bot mode
just demo                                 # terminal 2 — walkthrough
# When the live step starts, go type in your Discord channel.

See WALKTHROUGH.md for the full sequence diagram and step-by-step explanation. The walkthrough is generated from the demo step definitions in walkthrough.go — run just readme to regenerate.

Going to production? See experimental/ext/events/DEPLOYMENT.md for private-cloud / WAF guidance.

What it demonstrates

Each bullet maps to a step in the demokit walkthrough:

  • events/list — the source catalog, including the cursorless flag.
  • Push — long-lived events/stream SSE; notifications/events/event arrives in real time.
  • Poll — single-subscription events/poll (multi-sub batching is not supported).
  • Cursorless source — typing indicators wired as cursor: null; subscribers see live events but can’t replay.
  • Webhook + auto-refreshevents/subscribe driven via the typed Subscription + Receiver[Data] from clients/go, including HMAC verification and 0.5×TTL re-subscribe.
  • Source-side health signalsYieldError(err) (transient → notifications/events/error) and YieldTerminated(err) (terminal → notifications/events/terminated).
  • Spec validation — empty / malformed delivery.secret rejected with -32602; client-supplied id rejected; valid whsec_ accepted with no secret echoed in the response.
  • Live Discord interaction — real typing + message events from a Discord channel (when started with DISCORD_BOT_TOKEN).

Setup — getting a Discord bot token (Option B only)

Skip this section if you’re running in test mode (Option A above).

Getting a Discord Bot Token

  1. Go to https://discord.com/developers/applications
  2. Click New Application, name it (e.g., “MCPKit Events”)
  3. Go to Bot tab → click Reset Token → copy the token
  4. Under Privileged Gateway Intents, enable Message Content Intent
  5. Go to OAuth2URL Generator:
    • Scopes: bot
    • Bot Permissions: Send Messages, Read Message History
  6. Copy the generated URL and open it to invite the bot to your server

The typing-indicator step in the walkthrough additionally needs the IntentsGuildMessageTyping intent — the server requests it automatically when started with -token, no extra dev-portal toggle required (typing isn’t classified as a privileged intent).

Architecture

Discord Bot (WebSocket)  ──or──  POST /inject
                │                       │
                ▼                       ▼
                yield(DiscordEventData{...})    ← user code: one call
                │
                │  YieldingSource (library):
                │    - assigns cursor + event ID
                │    - stores in bounded ring (1000 max)
                │    - calls library-installed fanout hook
                ▼
                ├──► events.Emit()              → Push (SSE broadcast)
                └──► events.EmitToWebhooks()     → Webhook (HMAC POST)
                                                   ▲
                                              events/poll reads from
                                              the same source's buffer

  Resource handlers read typed payloads via:
    source.Recent(50)         → []DiscordEventData
    source.ByCursor("42")     → (DiscordEventData, true)

The discord callback writes one line — yield(...) — and the library handles cursor assignment, retention, push fanout, webhook fanout, and typed read access. The YieldingSource’s internal buffer is the single source of truth, exposed as both an EventSource (for events/poll) and as typed accessors (for resource reads).

Event payload shape

Discord events have a richer structure than Telegram — nested author, optional threads, embeds, and mentions. The payloadSchema in events/list is auto-derived from the Go struct:

{
  "guild_id": "123456",
  "channel_id": "789012",
  "message_id": "evt_1",
  "author": { "id": "111", "username": "alice", "bot": false },
  "content": "hello world",
  "type": "default",
  "thread": { "id": "999", "name": "discussion", "parent_id": "789012" },
  "embeds": [{ "title": "Link Preview", "url": "https://..." }],
  "mentions": ["bob", "carol"],
  "ts": "2026-04-16T12:00:00Z"
}

Server flag examples (outside the walkthrough)

The walkthrough runs against the default server config. To exercise the other modes, pass flags to just serve:

# Opt out of the Standard Webhooks default back to legacy X-MCP-* headers
go run . --serve -webhook-header-mode mcp

# Drive a short TTL to watch the SDK's auto-refresh behavior in real time
go run . --serve -webhook-ttl 5s

Per spec, the webhook signing secret is client-supplied only (whsec_ + base64 of 24-64 random bytes). The python just webhook and the Go SDK both auto-generate when the application doesn’t supply one. See experimental/ext/events/README.md for the full configuration reference.

Auth posture: demo escape vs real OIDC

Per spec §“Subscription Identity” L361 webhook subscribe MUST require an authenticated principal. The demo auto-detects which posture to run in based on environment variables:

# Demo posture (default): no env vars set.
# Server runs anonymous; events.Config.UnsafeAnonymousPrincipal="demo-user"
# is the escape hatch so just demo works end-to-end without an OIDC provider.
# Server logs:  [server] auth: demo (anonymous → UnsafeAnonymousPrincipal)
just serve

# Real-OIDC posture: set OAUTH_ISSUER (and optionally OAUTH_AUDIENCE / OAUTH_JWKS_URL).
# Server wires server.WithAuth(JWTValidator) and follows the spec strictly —
# anonymous webhook subscribes are rejected with -32012 Unauthorized.
# Server logs:  [server] auth: real OIDC (...) — anonymous webhook subscribes rejected per spec
OAUTH_ISSUER=http://localhost:8081/realms/demo \
OAUTH_AUDIENCE=mcp-events \
just serve

Recognized env vars:

Env var Default Notes
OAUTH_ISSUER (unset → demo posture) OIDC issuer URL. For Keycloak: http://localhost:8081/realms/<realm>. Setting this enables real auth.
OAUTH_JWKS_URL <issuer>/protocol/openid-connect/certs Override for non-Keycloak providers.
OAUTH_AUDIENCE mcp-events Tokens MUST have this audience claim.

The events package itself depends only on core.Claims (the abstract auth contract), not on any specific auth implementation — see experimental/ext/events/README.md “Auth + extension composition” for the design rationale.

Where to look in the code

  • examples/events/discord/main.go:serve — server bootstrap (auth posture, source registration, /inject side endpoint via server.WithMux).
  • examples/events/discord/walkthrough.go:runDemo — demokit script driving every step in the bullet list above.
  • examples/events/discord/events.go:newDiscordSourceevents.YieldingSource[DiscordEventData] construction (cursored).
  • examples/events/discord/events.go:newDiscordTypingSource — cursorless typing source via events.WithoutCursors().
  • examples/events/discord/handlers.go:registerResources — typed resource handlers backed by the source’s buffer.
  • examples/events/discord/handlers.go:registerToolsdiscord.send_message tool.
  • experimental/ext/events/clients/go/subscription.go:Subscribe — Go SDK subscribe + auto-refresh helper used in step 6.
  • experimental/ext/events/clients/go/receiver.go:NewReceiver — typed inbound webhook receiver with HMAC verification.
  • experimental/ext/events/webhook.go:WebhookRegistry — per-subscription delivery + signing + retry.

Make targets

Target Description
just serve Start the server (with bot if DISCORD_BOT_TOKEN set; test mode otherwise)
just demo Run the demokit walkthrough — --tui mode
just readme Regenerate WALKTHROUGH.md from the demo step definitions
just build Build the binary
just test Go tests
just test-ttl Drive the Python WebhookSubscription auto-refresh helper end-to-end (POSIX-only — see Makefile for Windows steps)
TEXT=... just inject TEXT="..." Inject a message event (optional: SENDER=, CHANNEL=, GUILD=)
TEXT=... just inject-typing Inject a cursorless typing event (optional: USER_NAME=, CHANNEL=, GUILD=)
make list Show server capabilities via Python client (tools, resources, events, sample poll)
make listen Python SSE push listener
just webhook Python webhook receiver — subscribe + auto-refresh, receive HMAC-signed POSTs
make poll Python polling loop (default 5s interval, override: INTERVAL=10)

The Python clients (make list / listen / webhook / poll) are convenient for ad-hoc poking. The walkthrough above (just demo) is the canonical tour. Both share the events_client.py helper.

Next steps


Walkthrough

MCP Events Extension — Discord reference walkthrough

Walks through the four delivery modes of the experimental MCP Events extension (events/list, push via SSE, poll, webhook with TTL refresh) plus the cursored vs cursorless source distinction. Webhook subscriber uses the typed Go SDK at experimental/ext/events/clients/go.

What you’ll learn

  • How do I open the conversation? — Vanilla MCP initialize over Streamable HTTP. The events extension doesn’t declare any new capability — events/* methods are registered server-side via the events library. Push delivery rides a long-lived per-subscription POST that returns SSE (events/stream), not the session GET back-channel, so the client doesn’t need any transport-level wiring to receive events.
  • What kinds of events does this server even emit?events/list returns the catalog of event types the server can emit — not a list of recent event instances. (The naming is a touch misleading: it’s much closer in spirit to tools/list than to a CRUD listing. Think of each entry as the schema for a kind of event that subscribers can ask for, not as data.)
  • Can I get events as they happen? — Yes — events/stream is the answer. It’s a long-lived JSON-RPC request, one per subscription, that returns its events as notifications/events/event frames on the call’s own SSE response stream. Spec §“Push-Based Delivery” L223-296.
  • What if I can’t keep a long-lived stream open? — Poll instead. events/poll is single-subscription per call (multi-sub batching was removed) with a flat top-level shape: {name, params, cursor, maxAge, maxEvents} in, {events, cursor, hasMore, truncated, nextPollSeconds} out. Polling at the head returns no new events but advances the cursor — the response shape is identical whether or not events are waiting, so the client’s polling loop has one code path.
  • What about events I don’t need to replay, like ‘user is typing’? — On the wire, the event type is marked cursorless: events/list advertises cursorless: true for that EventDef, every event delivery emits cursor: null, and events/poll always returns empty with cursor: null (there’s nothing buffered to serve). Push delivery still fans out events live — the only thing that changes versus a cursored source is replay. (in mcpkit: source authors opt in via events.NewYieldingSource[T](def, events.WithoutCursors()))
  • What happens when the upstream source has a hiccup? — On the wire, two notification methods carry source health. notifications/events/error (spec L255+L261) is transient — the source had a failure, the stream stays open, subsequent events still arrive. notifications/events/terminated (spec L783-795) is terminal — the subscription has ended. This step exercises the transient path: inject?action=error causes the source to surface one upstream failure, the open stream sees notifications/events/error arrive while staying connected. (in mcpkit: server authors trigger these via source.YieldError(err) / source.YieldTerminated(err))
  • What if my client itself keeps restarting, but I have a public callback URL? — Use webhook delivery. events/subscribe registers a callback URL plus a client-supplied whsec_ secret with a TTL; the server POSTs HMAC-signed events to that URL as they happen, the subscription is soft-state on the server (in-memory with TTL), and the client refreshes before refreshBefore to keep it alive. If the client process dies and reconnects later with the same canonical tuple, the subscription either is still alive (refresh is idempotent) or has lapsed and the next subscribe creates a fresh one with the supplied cursor as the replay point. (in mcpkit: clients/go provides Subscription for subscribe + auto-refresh and Receiver[Data] for a typed inbound channel)
  • Two subs to the same event with different params — how do I tell deliveries apart? — Each delivery POST carries its own X-MCP-Subscription-Id header (per spec §“Webhook Event Delivery” L390), and on the push side every notification echoes the originating events/stream request id in params.requestId. Subscriptions are identified by the canonical tuple (principal, delivery.url, name, params) (spec §“Subscription Identity” → “Key composition” L363), so two subscribes with the same (principal, url, name) but different params produce different ids — and the receiver branches by header without parsing the body.
  • My webhook receiver just died. How does the server let me know? — Two answers, layered. First, every subscribe-refresh response carries a deliveryStatus block when the target has prior delivery attempts (spec §“Webhook Delivery Status” L425-460): active / lastDeliveryAt / lastError / failedSince. Second, after N consecutive failures within a sliding window, the server flips active: false and auto-Posts a {type:terminated} control envelope to the receiver as a courtesy heads-up. Refresh of a suspended target reactivates it.
  • What if I forget the secret? — Rejected with -32602 InvalidParams at subscribe time. delivery.secret is REQUIRED on every events/subscribe per spec — there’s no server-side fallback. Rejecting at subscribe time means a malformed subscription never exists in the registry, so the server can’t ever produce unverifiable deliveries.
  • What if I supply garbage instead of a whsec_ value? — Rejected with -32602 InvalidParams. The validator enforces the full Standard Webhooks format: whsec_ followed by base64 of 24-64 random bytes. A non-prefixed value, a too-short value, or non-base64 garbage all fail at subscribe time — catches IaC-pinned secrets that don’t match the spec format before they create a broken subscription.
  • What if I try to pick my own subscription id? — Rejected with -32602 InvalidParams. Per spec §“Subscription Identity” → “Key composition” L363, the id is server-derived from (principal, name, params, url) — there is no client-generated id. Old SDKs that send an id field get a loud error rather than a silent mis-keying that would alias subscriptions and break tenant isolation.
  • And when everything is right? — Subscribe succeeds. The response carries the server-derived id (sub_<base64> per spec §“Subscription Identity” → “Derived id” L367), plus cursor and refreshBefore. Notably absent: the secret — the client supplied it, so the server doesn’t echo it back. Echoing would risk leaks via proxies, logs, or IDE network panes.
  • Now let’s see it against a real bot — Setup: start the server with a Discord bot token and invite the bot to a channel you can post in.

Flow

sequenceDiagram
    participant Host as MCP Host (this client)
    participant Server as MCP Server (just serve)
    participant Receiver as Local webhook receiver (this process)
    participant Discord as Discord (real bot mode only)

    Note over Host,Discord: Step 1: How do I open the conversation?
    Host->>Server: POST /mcp — initialize
    Server-->>Host: serverInfo + capabilities

    Note over Host,Discord: Step 2: What kinds of events does this server even emit?
    Host->>Server: events/list
    Server-->>Host: [discord.message (cursored), discord.typing (cursorless)]

    Note over Host,Discord: Step 3: Can I get events as they happen?
    Host->>Server: events/stream { name: discord.message }
    Server-->>Host: notifications/events/active { requestId, cursor }
    Receiver->>Server: POST /inject (simulated Discord message)
    Server-->>Host: notifications/events/event { requestId, eventId, ... }
    Host-->>Server: (close request) → StreamEventsResult final frame

    Note over Host,Discord: Step 4: What if I can't keep a long-lived stream open?
    Host->>Server: events/poll {name: discord.message, cursor: <head>}
    Server-->>Host: {events: [], cursor: <head>, hasMore: false}

    Note over Host,Discord: Step 5: What about events I don't need to replay, like 'user is typing'?
    Host->>Server: events/stream { name: discord.typing }
    Server-->>Host: notifications/events/active { cursor: null }
    Receiver->>Server: POST /inject?event=discord.typing
    Server-->>Host: notifications/events/event { cursor: null }

    Note over Host,Discord: Step 6: What happens when the upstream source has a hiccup?
    Host->>Server: events/stream { name: discord.message }
    Server-->>Host: notifications/events/active
    Receiver->>Server: POST /inject?action=error
    Server-->>Host: notifications/events/event/error { requestId, error: { code, message } }

    Note over Host,Discord: Step 7: What if my client itself keeps restarting, but I have a public callback URL?
    Receiver->>Receiver: spin up local httptest receiver on :random
    Host->>Server: events/subscribe { mode: webhook, url, secret: whsec_<client-supplied> }
    Server-->>Host: { id, refreshBefore }   (response does NOT echo secret per spec)
    Receiver->>Server: POST /inject (simulated message)
    Server-->>Receiver: POST <url> + HMAC signature headers (default webhook-* per Standard Webhooks, opt-in X-MCP-* via -webhook-header-mode mcp)
    Host-->>Host: background loop: re-subscribe at 0.5 × TTL

    Note over Host,Discord: Step 8: Two subs to the same event with different params — how do I tell deliveries apart?
    Host->>Server: events/subscribe { name: discord.message, params: {channel_id: 'alpha'}, ... }
    Server-->>Host: { id: sub_<A>, ... }
    Host->>Server: events/subscribe { name: discord.message, params: {channel_id: 'beta'}, ... }
    Server-->>Host: { id: sub_<B>, ... }   (id differs from A — different params → different canonical tuple)
    Receiver->>Server: POST /inject (one event)
    Server-->>Receiver: POST <url> + X-MCP-Subscription-Id: sub_<A>
    Server-->>Receiver: POST <url> + X-MCP-Subscription-Id: sub_<B>

    Note over Host,Discord: Step 9: My webhook receiver just died. How does the server let me know?
    Receiver->>Receiver: spin up failing receiver (returns 500 on event POSTs)
    Host->>Server: events/subscribe { name: discord.message, ... }
    Server-->>Host: { id, refreshBefore }   (no deliveryStatus on first subscribe — nothing to report)
    Receiver->>Server: POST /inject (one event)
    Server-->>Receiver: POST <url>  → 500  (×4 retries with exponential backoff, then recordDeliveryFailure)
    Host->>Server: events/subscribe (refresh — same canonical tuple)
    Server-->>Host: { id, refreshBefore, deliveryStatus: { active, lastDeliveryAt, lastError, failedSince } }
    Server-->>Receiver: (if suspend fires) POST <url> body={type:terminated, error}  + webhook-id=msg_terminated_<random>

    Note over Host,Discord: Step 10: What if I forget the secret?
    Host->>Server: events/subscribe { delivery: { ... } }   (no secret)
    Server-->>Host: -32602 InvalidParams: delivery.secret is required

    Note over Host,Discord: Step 11: What if I supply garbage instead of a `whsec_` value?
    Host->>Server: events/subscribe { delivery: { secret: 'wrong' } }
    Server-->>Host: -32602 InvalidParams: delivery.secret invalid: must start with the whsec_ prefix

    Note over Host,Discord: Step 12: What if I try to pick my own subscription id?
    Host->>Server: events/subscribe { id: 'mine', ... }
    Server-->>Host: -32602 InvalidParams: client-supplied id is not accepted

    Note over Host,Discord: Step 13: And when everything is right?
    Host->>Host: events.GenerateSecret() → whsec_<base64 of 32 bytes>
    Host->>Server: events/subscribe { delivery: { secret: whsec_<valid> } }
    Server-->>Host: { id: sub_<base64-of-16-bytes>, cursor, refreshBefore }   (no secret per spec)

    Note over Host,Discord: Step 14: Now let's see it against a real bot
    Discord->>Server: TypingStart event (when you start typing in the channel)
    Server-->>Host: notifications/events/event { name: discord.typing, cursor: null }
    Discord->>Server: MessageCreate event (when you press enter)
    Server-->>Host: notifications/events/event { name: discord.message, cursor: <fresh> }

Steps

Setup — two modes

This walkthrough runs against either a test-mode server or a real Discord bot.

Option A — Test mode (no bot token needed). All steps run; the final live-interaction step skips with a ’no token’ message. Drive synthetic events from a third terminal via make inject / make inject-typing.

Terminal 1:  just serve                                # server in test mode
Terminal 2:  just demo                                 # this walkthrough
Terminal 3:  just inject TEXT='hello'                  # message event
             make inject-typing                        # typing event (cursorless)

Option B — Real bot mode (requires DISCORD_BOT_TOKEN). Same walkthrough plus the live step captures real typing + message events from your Discord channel. Token setup in the demo’s README.

Terminal 1:  DISCORD_BOT_TOKEN=... just serve          # server in bot mode
Terminal 2:  just demo                                 # this walkthrough
             # In Discord: type, then send. Live step captures both.

What this demo covers

  • events/list — the source catalog, including the cursorless flag and the _meta per-type metadata field.
  • Push — long-lived SSE stream; notifications/events/event arrives in real time.
  • Poll — single-subscription events/poll (multi-sub batching is not supported).
  • Cursorless source — typing indicators that wire as cursor: null. Subscribers can’t replay, only see live events.
  • Source-side health signalsYieldError (transient notifications/events/error, stream stays open).
  • Webhook + auto-refreshevents/subscribe with the typed Subscription + Receiver[Data] from clients/go. Includes the hardened delivery loop: dial-time SSRF guard, no-redirects, 256 KiB body cap with 413 non-retryable, Standard Webhooks signature scheme as default.
  • Multi-subscription routing — two subs to discord.message with different params; one event fans out to both, distinguished by X-MCP-Subscription-Id plus push-side requestId echo on every notification.
  • Webhook delivery healthdeliveryStatus block on subscribe-refresh response after a failed delivery; suspend state machine flips Active=false after N consecutive failures and auto-Posts a {type:terminated} control envelope when run with just serve-fast-suspend.
  • Auth postureevents/subscribe requires an authenticated principal per spec; demo runs anonymously via UnsafeAnonymousPrincipal. Production deployments wire real OIDC and reject anonymous subscribes with -32012 Forbidden.
  • Spec validation — empty / malformed delivery.secret rejected; client-supplied id rejected; valid whsec_ accepted with no secret echoed.

Identity-mode subscribe and Standard Webhooks header naming are exercised by the unit tests in experimental/ext/events/ and by discord-events’s e2e tests; they require the server to be started with mode flags so they’re documented in the README rather than driven from this walkthrough.

Step 1: How do I open the conversation?

Vanilla MCP initialize over Streamable HTTP. The events extension doesn’t declare any new capability — events/* methods are registered server-side via the events library. Push delivery rides a long-lived per-subscription POST that returns SSE (events/stream), not the session GET back-channel, so the client doesn’t need any transport-level wiring to receive events.

Reproduce on the wire

# Vanilla MCP initialize over Streamable HTTP — mints the session id.
# The events extension declares no new capability; events/* are server-side methods.
SID=$(curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
echo "SID=$SID"

Step 2: What kinds of events does this server even emit?

events/list returns the catalog of event types the server can emit — not a list of recent event instances. (The naming is a touch misleading: it’s much closer in spirit to tools/list than to a CRUD listing. Think of each entry as the schema for a kind of event that subscribers can ask for, not as data.)

Each entry advertises a name, description, the supported delivery modes, an auto-derived payloadSchema, and the cursorless flag — discord.message buffers events and accepts replay cursors, discord.typing emits ephemerally and always wires cursor: null.

  • Each EventDef may carry an opaque _meta map for app-defined per-event-type metadata (mirrors the _meta convention on Tool / Resource / Prompt in base MCP). The same _meta convention applies on EventOccurrence (the wire-format Event envelope). The discord sources don’t set _meta here; servers that want to surface trace ids, source-system tags, or other per-type annotations populate it on construction.
  • The events/list response carries an optional nextCursor for forward-compatible pagination (mirrors the tools/list / resources/list convention). Library doesn’t paginate today (advertised sets are small in practice); the field is plumbed for forward compatibility.

Reproduce on the wire

# events/list returns the catalog of event TYPES (closer to tools/list than a CRUD listing).
# Each entry advertises name, deliveryModes, payloadSchema, the cursorless flag, and optional _meta.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"events/list","params":{}}' | jq '.result'

Step 3: Can I get events as they happen?

Yes — events/stream is the answer. It’s a long-lived JSON-RPC request, one per subscription, that returns its events as notifications/events/event frames on the call’s own SSE response stream. Spec §“Push-Based Delivery” L223-296.

  • Server confirms with notifications/events/active, then delivers events as notifications/events/event on the call’s own SSE response stream.
  • Heartbeats fire every ≥30s carrying the source’s current cursor so the client’s persisted cursor advances during quiet periods.
  • Replaces the broadcast-to-all-listeners model from Phase 1; per-stream isolation comes for free since each stream is its own POST.
  • Typed Go SDK Stream() helper (experimental/ext/events/clients/go) threads the per-call notification hook (client.CallContext.WithNotifyHook) so callbacks fire only for THIS stream’s notifications.

Reproduce on the wire

# events/stream is a long-lived JSON-RPC request; its events arrive as
# notifications/events/event frames on the call's own SSE response stream.
# Server first confirms with notifications/events/active { requestId, cursor }.
# (Inject a message from another terminal: make inject TEXT='hello')
curl -N -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"events/stream","params":{"name":"discord.message"}}'

Step 4: What if I can’t keep a long-lived stream open?

Poll instead. events/poll is single-subscription per call (multi-sub batching was removed) with a flat top-level shape: {name, params, cursor, maxAge, maxEvents} in, {events, cursor, hasMore, truncated, nextPollSeconds} out. Polling at the head returns no new events but advances the cursor — the response shape is identical whether or not events are waiting, so the client’s polling loop has one code path.

Reproduce on the wire

# events/poll is single-subscription per call with a flat top-level shape.
# Polling at the head returns no new events but advances the cursor — same shape either way.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":3,"method":"events/poll","params":{"name":"discord.message","cursor":"0"}}' | jq '.result'

Step 5: What about events I don’t need to replay, like ‘user is typing’?

On the wire, the event type is marked cursorless: events/list advertises cursorless: true for that EventDef, every event delivery emits cursor: null, and events/poll always returns empty with cursor: null (there’s nothing buffered to serve). Push delivery still fans out events live — the only thing that changes versus a cursored source is replay. (in mcpkit: source authors opt in via events.NewYieldingSource[T](def, events.WithoutCursors()))

  • Push delivery via events/stream still works — there’s just nothing to replay.
  • Heartbeats also carry cursor:null (spec L294: “null for event types that do not support replay”).
  • Useful for ephemeral state (typing indicators, presence, current readings).

Reproduce on the wire

# Same events/stream method, but discord.typing is a cursorless source:
# every delivery (and heartbeat) carries cursor:null — push still fans out live,
# only replay is unavailable. (Inject from another terminal: make inject-typing)
curl -N -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":4,"method":"events/stream","params":{"name":"discord.typing"}}'

Step 6: What happens when the upstream source has a hiccup?

On the wire, two notification methods carry source health. notifications/events/error (spec L255+L261) is transient — the source had a failure, the stream stays open, subsequent events still arrive. notifications/events/terminated (spec L783-795) is terminal — the subscription has ended. This step exercises the transient path: inject?action=error causes the source to surface one upstream failure, the open stream sees notifications/events/error arrive while staying connected. (in mcpkit: server authors trigger these via source.YieldError(err) / source.YieldTerminated(err))

  • Webhook subscribers don’t see error envelopes (errors are upstream-side, not delivery-side); they DO see {type:terminated} control envelopes when the suspend state machine flips Active=false or when the source itself terminates.
  • This walkthrough step exercises only the transient error path — calling inject?action=terminate would one-shot terminate the discord.message source, breaking subsequent walkthrough steps that depend on it. Full terminate flow is covered by TestE2EHealthSignalsEndToEnd in this demo’s e2e_test.go.

Reproduce on the wire

# Open an events/stream, then trigger a transient upstream failure on the source.
# notifications/events/error arrives on the open stream — which stays connected.
# (notifications/events/terminated would be terminal; this path is transient only.)
curl -N -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":5,"method":"events/stream","params":{"name":"discord.message"}}' &
curl -s -X POST 'http://localhost:8080/inject?action=error&code=-32603&message=demo+upstream+failure'

Step 7: What if my client itself keeps restarting, but I have a public callback URL?

Use webhook delivery. events/subscribe registers a callback URL plus a client-supplied whsec_ secret with a TTL; the server POSTs HMAC-signed events to that URL as they happen, the subscription is soft-state on the server (in-memory with TTL), and the client refreshes before refreshBefore to keep it alive. If the client process dies and reconnects later with the same canonical tuple, the subscription either is still alive (refresh is idempotent) or has lapsed and the next subscribe creates a fresh one with the supplied cursor as the replay point. (in mcpkit: clients/go provides Subscription for subscribe + auto-refresh and Receiver[Data] for a typed inbound channel)

  • HMAC signing secret is client-supplied per spec; SDK auto-generates a whsec_ value via events.GenerateSecret() when SubscribeOptions.Secret is empty.
  • Subscription.Secret() returns the value the SDK ended up using, so the receiver can verify with the same secret.
  • Receiver[DiscordEventData] verifies signatures and decodes the wire envelope into the typed Data shape — consumer reads ev.Data.Content directly, no re-parsing JSON.
  • Default header mode is Standard Webhooks (spec L390+L431): webhook-id / webhook-timestamp / webhook-signature: v1,<base64> plus the MCP-specific X-MCP-Subscription-Id. Off-the-shelf Svix-style verifiers work. MCPHeaders (X-MCP-Signature + X-MCP-Timestamp) is the opt-in legacy via -webhook-header-mode mcp.

Hardened delivery loop (webhook.go deliver()):

  • Dial-time SSRF guard rejects loopback / RFC1918 / link-local / IPv6-ULA / multicast at the net.Dialer.Control callback (TOCTOU-safe under DNS rebinding). The demo bypasses this via WithWebhookAllowPrivateNetworks(true) because it delivers to a local httptest receiver; production deployments leave the guard ON. Spec §“Webhook Security” L464.
  • No-redirect-following: http.Client.CheckRedirect returns ErrUseLastResponse so a receiver returning 3xx to an internal address can’t bypass the dial-time guard via Go’s redirect chain. 3xx is terminal http_3xx_redirect.
  • 256 KiB body cap (REJECT not TRUNCATE — truncation would corrupt the HMAC); 413 from the receiver is non-retryable. Spec L487.
  • 5xx retry with exponential backoff (4 attempts: 500ms → 1s → 2s → 5s cap). Standard webhook convention.

Auth posture: events/subscribe requires an authenticated principal per spec §“Subscription Identity” → “Authentication required” L361. The demo runs anonymously via events.Config.UnsafeAnonymousPrincipal="demo-user" (logged at startup as “auth: demo (anonymous → UnsafeAnonymousPrincipal)”). Production deployments unset that field AND wire server.WithAuth(JWTValidator) so anonymous subscribes hit the spec-mandated -32012 Forbidden. See README “Auth posture: demo escape vs real OIDC”.

Reproduce on the wire

# events/subscribe registers a callback URL + a client-supplied whsec_ secret with a TTL.
# Response carries { id, refreshBefore } and does NOT echo the secret (spec).
# Tear down by tuple (name, params, delivery.url) — the derived id is not accepted as input.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":6,"method":"events/subscribe","params":{"name":"discord.message","delivery":{"mode":"webhook","url":"https://receiver.example/hook","secret":"whsec_<client-supplied>"},"maxAge":300}}' | jq '.result'
# later: events/unsubscribe { name, delivery: { url } }

Step 8: Two subs to the same event with different params — how do I tell deliveries apart?

Each delivery POST carries its own X-MCP-Subscription-Id header (per spec §“Webhook Event Delivery” L390), and on the push side every notification echoes the originating events/stream request id in params.requestId. Subscriptions are identified by the canonical tuple (principal, delivery.url, name, params) (spec §“Subscription Identity” → “Key composition” L363), so two subscribes with the same (principal, url, name) but different params produce different ids — and the receiver branches by header without parsing the body.

  • The library fans out one yielded event to both webhook targets by default. Authors that want per-subscription filtering attach a Match (and optionally Transform) hook on the EventDef — the hook fires once per (event × subscription) on the fanout step and short-circuits delivery for non-matching subs. The discord demo doesn’t wire one because params-based routing (different ids per (name, params) tuple) is enough for the “two subs, same event, different params” story.
  • Push side: the same routing works via the requestId echo on every notifications/events/event payload — each events/stream POST gets its own JSON-RPC id, and notifications carry it in params.requestId.

Reproduce on the wire

# Two subscribes, same (principal, url, name) but different params → different ids.
# One event then fans out to both; each delivery POST carries its own X-MCP-Subscription-Id.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":7,"method":"events/subscribe","params":{"name":"discord.message","params":{"channel_id":"alpha"},"delivery":{"mode":"webhook","url":"https://receiver.example/hook","secret":"whsec_<a>"}}}' | jq '.result.id'
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":8,"method":"events/subscribe","params":{"name":"discord.message","params":{"channel_id":"beta"},"delivery":{"mode":"webhook","url":"https://receiver.example/hook","secret":"whsec_<b>"}}}' | jq '.result.id'
# later: events/unsubscribe per tuple — { name, params: { channel_id }, delivery: { url } }

Step 9: My webhook receiver just died. How does the server let me know?

Two answers, layered. First, every subscribe-refresh response carries a deliveryStatus block when the target has prior delivery attempts (spec §“Webhook Delivery Status” L425-460): active / lastDeliveryAt / lastError / failedSince. Second, after N consecutive failures within a sliding window, the server flips active: false and auto-Posts a {type:terminated} control envelope to the receiver as a courtesy heads-up. Refresh of a suspended target reactivates it.

  • lastError is from a closed categorical set (connection_refused, timeout, tls_error, http_3xx_redirect, http_4xx, http_5xx, challenge_failed); the spec forbids raw response bodies / headers / status lines because the subscribe response is visible to the subscriber and arbitrary receiver responses must not become a data oracle.
  • failedSince is set on the first failure of the current run and preserved across subsequent failures, so subscribers can see how long the receiver has been unreachable.
  • Spec §“Webhook Event Delivery” L413+L460: “after repeated failures the server SHOULD set active: false.” The transition fires after 5 consecutive failures within a 10-min sliding window. On the true→false transition the server auto-Posts a {type:terminated} control envelope to the (now-suspended) receiver — webhook-id prefix is msg_terminated_<random> so receivers can distinguish it from event deliveries (which use evt_<eventId>). (in mcpkit: knobs are events.WithWebhookSuspendThreshold(n) and events.WithWebhookSuspendWindow(d))
  • A successful refresh of a suspended target reactivates it: clears the failure run, resets lastError and failedSince, flips active back to true.

Fast-mode tip: with the default just serve (-webhook-suspend-threshold 5), this step demonstrates the deliveryStatus reporting (lastError populated, failedSince populated, active still true) — full suspend takes 5 failed deliveries × ~8.5s each. To see suspend fire after ONE failure (~12s total step time), restart the server with just serve-fast-suspend (sets -webhook-suspend-threshold 1).

Reproduce on the wire

# First subscribe has no deliveryStatus (nothing to report yet).
# After a failed delivery, re-subscribe on the SAME tuple → response carries
# deliveryStatus { active, lastDeliveryAt, lastError, failedSince }.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":9,"method":"events/subscribe","params":{"name":"discord.message","params":{"role":"health-demo"},"delivery":{"mode":"webhook","url":"https://dead-receiver.example/hook","secret":"whsec_<v>"}}}' | jq '.result'
# (inject an event, let the ~8.5s retry cycle exhaust, then re-issue the SAME subscribe to see deliveryStatus)

Step 10: What if I forget the secret?

Rejected with -32602 InvalidParams at subscribe time. delivery.secret is REQUIRED on every events/subscribe per spec — there’s no server-side fallback. Rejecting at subscribe time means a malformed subscription never exists in the registry, so the server can’t ever produce unverifiable deliveries.

  • This step makes a raw client.Call to bypass the SDK and demonstrate the server-side validator directly. (in mcpkit: the Go SDK auto-generates a conforming whsec_ value via events.GenerateSecret() when SubscribeOptions.Secret is empty — this step skips that on purpose)

Reproduce on the wire

# delivery.secret is REQUIRED on every events/subscribe — no server-side fallback.
# Omitting it is rejected at subscribe time with -32602 InvalidParams.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":10,"method":"events/subscribe","params":{"name":"discord.message","delivery":{"mode":"webhook","url":"http://localhost:1/sink"}}}' | jq '.error'

Step 11: What if I supply garbage instead of a whsec_ value?

Rejected with -32602 InvalidParams. The validator enforces the full Standard Webhooks format: whsec_ followed by base64 of 24-64 random bytes. A non-prefixed value, a too-short value, or non-base64 garbage all fail at subscribe time — catches IaC-pinned secrets that don’t match the spec format before they create a broken subscription.

Reproduce on the wire

# A non-whsec_ value fails the Standard Webhooks format check → -32602 InvalidParams.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":11,"method":"events/subscribe","params":{"name":"discord.message","delivery":{"mode":"webhook","url":"http://localhost:1/sink","secret":"wrong"}}}' | jq '.error'

Step 12: What if I try to pick my own subscription id?

Rejected with -32602 InvalidParams. Per spec §“Subscription Identity” → “Key composition” L363, the id is server-derived from (principal, name, params, url) — there is no client-generated id. Old SDKs that send an id field get a loud error rather than a silent mis-keying that would alias subscriptions and break tenant isolation.

Reproduce on the wire

# The id is server-derived; a client-supplied id field is rejected → -32602 InvalidParams.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":12,"method":"events/subscribe","params":{"id":"client-picked-id","name":"discord.message","delivery":{"mode":"webhook","url":"http://localhost:1/sink","secret":"whsec_<valid>"}}}' | jq '.error'

Step 13: And when everything is right?

Subscribe succeeds. The response carries the server-derived id (sub_<base64> per spec §“Subscription Identity” → “Derived id” L367), plus cursor and refreshBefore. Notably absent: the secret — the client supplied it, so the server doesn’t echo it back. Echoing would risk leaks via proxies, logs, or IDE network panes.

  • The id is non-load-bearing for security; it’s surfaced as X-MCP-Subscription-Id on delivery POSTs but knowing the value grants no operations on the subscription.

Reproduce on the wire

# A valid whsec_ secret succeeds: response carries the server-derived id, cursor,
# and refreshBefore — and notably NOT the secret (the server never echoes it).
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":13,"method":"events/subscribe","params":{"name":"discord.message","delivery":{"mode":"webhook","url":"http://localhost:1/sink","secret":"whsec_<valid>"}}}' | jq '.result'
# then tear down by tuple: events/unsubscribe { name, delivery: { url } }

Step 14: Now let’s see it against a real bot

Setup: start the server with a Discord bot token and invite the bot to a channel you can post in.

DISCORD_BOT_TOKEN=<your-token> just serve

Bot setup (token + invite URL) is documented in this demo’s README.md.

  • TypingStart handler in main.go yields a cursorless discord.typing event; MessageCreate yields the cursored discord.message.
  • Discord’s typing indicator fires once when you start (then refires every ~8s if you keep typing), not per keystroke.
  • –non-interactive mode skips the wait so CI runs aren’t slowed.

Reproduce on the wire

# Hold two concurrent events/stream requests open against the same session,
# one per subscription (spec L271). Then type + send in the real Discord channel:
# discord.typing arrives with cursor:null, discord.message with a fresh cursor.
curl -N -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":14,"method":"events/stream","params":{"name":"discord.message"}}' &
curl -N -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":15,"method":"events/stream","params":{"name":"discord.typing"}}' &

Where each piece lives in mcpkit

  • Events library: experimental/ext/events/
  • Go client SDK (Subscription, Receiver[Data]): experimental/ext/events/clients/go/
  • Python client SDK (WebhookSubscription class + webhook CLI): experimental/ext/events/clients/python/events_client.py
  • Demo source: examples/events/discord/
  • Companion demo: examples/events/telegram/ (lighter walkthrough — same protocol, different bot SDK)

Run it

go run ./examples/events/discord/

Pass --non-interactive to skip pauses:

go run ./examples/events/discord/ --non-interactive