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.mdfor private-cloud / WAF guidance.
What it demonstrates ¶
Each bullet maps to a step in the demokit walkthrough:
- events/list — the source catalog, including the
cursorlessflag. - Push — long-lived
events/streamSSE;notifications/events/eventarrives 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-refresh —
events/subscribedriven via the typedSubscription+Receiver[Data]fromclients/go, including HMAC verification and 0.5×TTL re-subscribe. - Source-side health signals —
YieldError(err)(transient →notifications/events/error) andYieldTerminated(err)(terminal →notifications/events/terminated). - Spec validation — empty / malformed
delivery.secretrejected with-32602; client-suppliedidrejected; validwhsec_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 ¶
- Go to https://discord.com/developers/applications
- Click New Application, name it (e.g., “MCPKit Events”)
- Go to Bot tab → click Reset Token → copy the token
- Under Privileged Gateway Intents, enable Message Content Intent
- Go to OAuth2 → URL Generator:
- Scopes:
bot - Bot Permissions:
Send Messages,Read Message History
- Scopes:
- 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,/injectside endpoint viaserver.WithMux).examples/events/discord/walkthrough.go:runDemo— demokit script driving every step in the bullet list above.examples/events/discord/events.go:newDiscordSource—events.YieldingSource[DiscordEventData]construction (cursored).examples/events/discord/events.go:newDiscordTypingSource— cursorless typing source viaevents.WithoutCursors().examples/events/discord/handlers.go:registerResources— typed resource handlers backed by the source’s buffer.examples/events/discord/handlers.go:registerTools—discord.send_messagetool.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
Generated from the scripted demo — run it with make serve + make demo. Source: examples/events/discord/WALKTHROUGH.md
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
initializeover 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/listreturns 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 totools/listthan 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/streamis the answer. It’s a long-lived JSON-RPC request, one per subscription, that returns its events asnotifications/events/eventframes 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/pollis 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/listadvertisescursorless: truefor that EventDef, every event delivery emitscursor: null, andevents/pollalways returns empty withcursor: 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 viaevents.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=errorcauses the source to surface one upstream failure, the open stream seesnotifications/events/errorarrive while staying connected. (in mcpkit: server authors trigger these viasource.YieldError(err)/source.YieldTerminated(err)) - What if my client itself keeps restarting, but I have a public callback URL? — Use webhook delivery.
events/subscriberegisters a callback URL plus a client-suppliedwhsec_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 beforerefreshBeforeto 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/goprovidesSubscriptionfor subscribe + auto-refresh andReceiver[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-Idheader (per spec §“Webhook Event Delivery” L390), and on the push side every notification echoes the originatingevents/streamrequest id inparams.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 differentparamsproduce 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
deliveryStatusblock 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 flipsactive: falseand 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 InvalidParamsat subscribe time.delivery.secretis REQUIRED on everyevents/subscribeper 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 anidfield 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), pluscursorandrefreshBefore. Notably absent: thesecret— 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
cursorlessflag and the_metaper-type metadata field. - Push — long-lived SSE stream;
notifications/events/eventarrives 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 signals —
YieldError(transientnotifications/events/error, stream stays open). - Webhook + auto-refresh —
events/subscribewith the typedSubscription+Receiver[Data]fromclients/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.messagewith different params; one event fans out to both, distinguished byX-MCP-Subscription-Idplus push-siderequestIdecho on every notification. - Webhook delivery health —
deliveryStatusblock 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 withjust serve-fast-suspend. - Auth posture —
events/subscriberequires an authenticated principal per spec; demo runs anonymously viaUnsafeAnonymousPrincipal. Production deployments wire real OIDC and reject anonymous subscribes with-32012 Forbidden. - Spec validation — empty / malformed
delivery.secretrejected; client-suppliedidrejected; validwhsec_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
EventDefmay carry an opaque_metamap for app-defined per-event-type metadata (mirrors the_metaconvention on Tool / Resource / Prompt in base MCP). The same_metaconvention applies onEventOccurrence(the wire-format Event envelope). The discord sources don’t set_metahere; 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
nextCursorfor 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=terminatewould 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.Contentdirectly, no re-parsing JSON. - Default header mode is Standard Webhooks (spec L390+L431):
webhook-id/webhook-timestamp/webhook-signature: v1,<base64>plus the MCP-specificX-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.Controlcallback (TOCTOU-safe under DNS rebinding). The demo bypasses this viaWithWebhookAllowPrivateNetworks(true)because it delivers to a local httptest receiver; production deployments leave the guard ON. Spec §“Webhook Security” L464. - No-redirect-following:
http.Client.CheckRedirectreturnsErrUseLastResponseso a receiver returning 3xx to an internal address can’t bypass the dial-time guard via Go’s redirect chain. 3xx is terminalhttp_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 optionallyTransform) hook on theEventDef— 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
requestIdecho on everynotifications/events/eventpayload — eachevents/streamPOST gets its own JSON-RPC id, and notifications carry it inparams.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.
lastErroris 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.failedSinceis 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→falsetransition the server auto-Posts a{type:terminated}control envelope to the (now-suspended) receiver —webhook-idprefix ismsg_terminated_<random>so receivers can distinguish it from event deliveries (which useevt_<eventId>). (in mcpkit: knobs areevents.WithWebhookSuspendThreshold(n)andevents.WithWebhookSuspendWindow(d)) - A successful refresh of a suspended target reactivates it: clears the failure run, resets
lastErrorandfailedSince, flipsactiveback 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()whenSubscribeOptions.Secretis 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-Idon 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 (
WebhookSubscriptionclass +webhookCLI):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