Events — Telegram
The events protocol with a lighter, payload-focused walkthrough. Experimental.
Telegram 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 Telegram as the event source. Built on the experimental/ext/events library.
Companion to Clare Liguori’s TypeScript implementation. Also a lighter mirror of the discord-events demo — same protocol, different bot SDK.
Walkthrough ¶
A condensed walkthrough focused on the telegram-specific payload shape and the typed Go SDK at experimental/ext/events/clients/go/. Two ways to run it.
Option A — Test mode (no Telegram token needed) ¶
All walkthrough steps run; the final live-interaction step skips with a “no token” message.
just serve # terminal 1 — server in test mode
just demo # terminal 2 — walkthrough
To simulate Telegram activity from a third terminal:
TEXT=... just inject TEXT="hello world" # message event (cursored)
TEXT=... just inject-typing # typing indicator (cursorless, demo-only — see below)
Option B — Real bot mode (requires TELEGRAM_BOT_TOKEN) ¶
Same walkthrough plus the final live step captures real message events from a chat with the bot.
TELEGRAM_BOT_TOKEN=your-token just serve # terminal 1 — server in bot mode
just demo # terminal 2 — walkthrough
# When the live step starts, send a message to your bot in Telegram.
Note on typing events: Telegram’s Bot API doesn’t expose user typing events to bots — only the bot can send typing chat actions, not the other way around. So TEXT=... just inject-typing works as a demo of the cursorless wire shape, but Option B can’t capture real typing events. Discord can; see ../discord/WALKTHROUGH.md for the live-typing demo.
Generated walkthrough in WALKTHROUGH.md — regenerate via just readme. For the full protocol exposition (events/list, poll, secret modes, header modes, the spec’s design rationale) see ../discord/WALKTHROUGH.md. This README intentionally skips repeating what’s already in the walkthroughs.
Going to production? See
experimental/ext/events/DEPLOYMENT.mdfor private-cloud / WAF guidance.
What it demonstrates ¶
- The same MCP Events extension wire protocol as discord, against a Telegram-shaped event source.
- Push delivery via
events/stream(long-lived per-subscription POST returning SSE) carrying the telegram-specific payload (chat_id,user,text). - Cursorless event sources (
telegram.typing) —cursor: nullon the wire, no replay buffer. - The typed Go SDK at
experimental/ext/events/clients/go/consuming the sameStream()/Subscribe()helpers as discord, just with a differentDatashape. - A
/injectPOST endpoint (wired viaserver.WithMux) that synthesizes events for the test-mode walkthrough — same convention as discord-events.
Where to look in the code ¶
- Event source registration:
telegram.go,events.go - Webhook + inject handler wiring:
main.go(theserver.WithMuxcallback) - Walkthrough steps:
walkthrough.go - Spec library:
experimental/ext/events/ - Typed Go SDK:
experimental/ext/events/clients/go/ - Companion (full-protocol) walkthrough:
examples/events/discord/
Setup — getting a Telegram bot token (Option B only) ¶
Skip this section if you’re running in test mode (Option A above).
Get a bot token from @BotFather (/newbot). That’s it — no privileged-intent toggles like Discord has.
Architecture ¶
Telegram Bot (long-poll) ──or── POST /inject
│ │
▼ ▼
yield(TelegramEventData{...}) ← 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) → []TelegramEventData
source.ByCursor("42") → (TelegramEventData, true)
Server flag examples (outside the walkthrough) ¶
The walkthrough runs against the default server config. To exercise the legacy header mode, 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
Per spec, the webhook signing secret is client-supplied only (whsec_ + base64 of 24-64 random bytes). See experimental/ext/events/README.md for the full configuration reference.
Auth posture ¶
Same auto-detect pattern as the discord demo — see ../discord/README.md for the full env-var contract. TL;DR:
just serve # demo posture (anonymous escape)
OAUTH_ISSUER=http://localhost:8081/realms/demo just serve # real OIDC, spec-strict
Server logs which posture is active at startup.
Make targets ¶
| Target | Description |
|---|---|
just serve |
Start the server (with bot if TELEGRAM_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 |
TEXT=... just inject TEXT="..." |
Inject a message event (optional: SENDER=, CHAT_ID=) |
TEXT=... just inject-typing |
Inject a cursorless typing event (optional: USER_NAME=, CHAT_ID=) |
make list |
Show server capabilities via Python client |
make listen |
Python SSE push listener |
make webhook |
Python webhook receiver — subscribe + auto-refresh, receive HMAC-signed POSTs |
make poll |
Python polling loop (default 5s interval, override: INTERVAL=10) |
All Python clients share the events_client.py helper.
Next steps ¶
Walkthrough
Generated from the scripted demo — run it with make serve + make demo. Source: examples/events/telegram/WALKTHROUGH.md
MCP Events Extension — Telegram reference walkthrough ¶
A condensed walkthrough showing the same MCP Events extension wired against a Telegram-shaped event source. The protocol exposition lives in the discord walkthrough; this one focuses on the telegram-specific payload (chat_id, user, text) and the cursored vs cursorless distinction.
What you’ll learn ¶
- Connect to the events server — Plain MCP initialize over Streamable HTTP. Push delivery uses events/stream (a long-lived per-subscription POST that returns SSE), not the session GET stream — no transport-level wiring needed in the client.
- Push: open events/stream, inject a telegram message, observe per-call notifications — events/stream is a long-lived per-subscription POST returning SSE — see the discord walkthrough for the full protocol exposition. Telegram’s flat payload (chat_id, user, text) wires through the same Stream() helper as discord’s nested one; only the Data shape changes.
- Cursorless: open events/stream for telegram.typing, observe cursor:null — Telegram’s typing chat-action is ephemeral — no replay value, no buffer. Same WithoutCursors() story as discord.typing. Wire-shape contract per spec L294: cursorless emits cursor:null, never an empty string or absent key.
- Webhook: subscribe via the typed Go SDK, receive a TelegramEventData — Same
Subscription+Receiver[Data]pair as the discord webhook step. - Live Telegram interaction (real message from a Telegram chat) — Setup: start the server with a Telegram bot token and open a chat with the bot in the Telegram app.
Flow ¶
sequenceDiagram
participant Host as MCP Host (this client)
participant Server as MCP Server (just serve)
participant Receiver as Local webhook receiver (this process)
Note over Host,Receiver: Step 1: Connect to the events server
Host->>Server: POST /mcp — initialize
Server-->>Host: serverInfo + capabilities
Note over Host,Receiver: Step 2: Push: open events/stream, inject a telegram message, observe per-call notifications
Host->>Server: events/stream { name: telegram.message }
Server-->>Host: notifications/events/active { requestId, cursor }
Receiver->>Server: POST /inject (simulated telegram message)
Server-->>Host: notifications/events/event { requestId, data: {chat_id, user, text, ...} }
Note over Host,Receiver: Step 3: Cursorless: open events/stream for telegram.typing, observe cursor:null
Host->>Server: events/stream { name: telegram.typing }
Server-->>Host: notifications/events/event { cursor: null }
Note over Host,Receiver: Step 4: Webhook: subscribe via the typed Go SDK, receive a TelegramEventData
Receiver->>Receiver: spin up local httptest receiver on :random
Host->>Server: events/subscribe { mode: webhook, url, secret: whsec_<client-supplied>, name: telegram.message }
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)
Note over Host,Receiver: Step 5: Live Telegram interaction (real message from a Telegram chat)
Telegram->>Server: MessageCreate event (when you send a message to the bot)
Server-->>Host: notifications/events/event { name: telegram.message, cursor: <fresh> }
Steps ¶
Setup — two modes ¶
This walkthrough runs against either a test-mode server or a real Telegram 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, demo-only)
Option B — Real bot mode (requires TELEGRAM_BOT_TOKEN). Same walkthrough plus the live step captures real message events from a chat with the bot. Telegram’s Bot API doesn’t expose user typing events to bots, so the live step is message-only — see the live step’s note for details.
Terminal 1: TELEGRAM_BOT_TOKEN=... just serve # server in bot mode
Terminal 2: just demo # this walkthrough
# In Telegram: send a message to the bot. Live step captures it.
Why a separate telegram demo? ¶
The two demos share the same experimental/ext/events library and the same wire protocol. The differences are only in the payload shape (telegram has flat chat_id / text; discord has nested author and richer fields) and the bot SDK used to source events (telegram’s tgbotapi long-poll vs discord’s discordgo WebSocket).
For the full protocol exposition (events/list, poll, header modes, the spec’s design rationale) see examples/events/discord/WALKTHROUGH.md.
Step 1: Connect to the events server ¶
Plain MCP initialize over Streamable HTTP. Push delivery uses events/stream (a long-lived per-subscription POST that returns SSE), not the session GET stream — no transport-level wiring needed in the client.
Reproduce on the wire ¶
# initialize: mint the session id, then ack with notifications/initialized
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: Push: open events/stream, inject a telegram message, observe per-call notifications ¶
events/stream is a long-lived per-subscription POST returning SSE — see the discord walkthrough for the full protocol exposition. Telegram’s flat payload (chat_id, user, text) wires through the same Stream() helper as discord’s nested one; only the Data shape changes.
Reproduce on the wire ¶
# events/stream: long-lived POST returning SSE; notifications/events/event frames carry chat_id, user, text
# (mint $SID via initialize first — see the connect step)
curl -sN -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/stream","params":{"name":"telegram.message"}}'
Step 3: Cursorless: open events/stream for telegram.typing, observe cursor:null ¶
Telegram’s typing chat-action is ephemeral — no replay value, no buffer. Same WithoutCursors() story as discord.typing. Wire-shape contract per spec L294: cursorless emits cursor:null, never an empty string or absent key.
Reproduce on the wire ¶
# events/stream for a cursorless source: notifications/events/event frames carry cursor:null
# (mint $SID via initialize first — see the connect step)
curl -sN -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":"telegram.typing"}}'
Step 4: Webhook: subscribe via the typed Go SDK, receive a TelegramEventData ¶
Same Subscription + Receiver[Data] pair as the discord webhook step.
- Receiver[TelegramEventData] decodes the wire envelope’s Data field directly into TelegramEventData — consumer reads
ev.Data.Text, no re-parsing JSON. - The only differences from discord: the type parameter and the payload field names.
- SDK auto-generates a whsec_ secret when SubscribeOptions.Secret is empty (events.GenerateSecret).
Reproduce on the wire ¶
# events/subscribe in webhook mode; response carries id + refreshBefore but NOT the secret.
# cursor:null = "from now"; maxAge:300 bounds replay to 5 min. (follow-up: events/unsubscribe by {name,delivery.url})
# (mint $SID via initialize first — see the connect step)
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/subscribe","params":{"name":"telegram.message","delivery":{"mode":"webhook","url":"http://localhost:9999/hook","secret":"whsec_<client-supplied>"},"cursor":null,"maxAge":300}}' | jq '.result'
Step 5: Live Telegram interaction (real message from a Telegram chat) ¶
Setup: start the server with a Telegram bot token and open a chat with the bot in the Telegram app.
TELEGRAM_BOT_TOKEN=<your-token> just serve
Bot setup (BotFather token, chat link) is documented in this demo’s README.md.
- No typing parallel here — Telegram’s Bot API doesn’t expose user typing events to bots (only the bot can send typing chat actions, not the other way).
- Discord does have user-typing events; see ../discord/WALKTHROUGH.md for the live-typing demo.
- –non-interactive mode skips the wait so CI runs aren’t slowed.
Reproduce on the wire ¶
# Live capture: open events/stream and leave it running; each real Telegram message
# arrives as a notifications/events/event frame on the SSE response.
# (server must be in -token mode; mint $SID via initialize first — see the connect step)
curl -sN -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":"telegram.message"}}'
More ¶
For the full protocol walkthrough (events/list, poll, header modes, the spec’s design rationale) see examples/events/discord/WALKTHROUGH.md.
Both demos share experimental/ext/events (library), clients/go/ (Go SDK), and clients/python/events_client.py (Python SDK).
Run it ¶
go run ./examples/events/telegram/
Pass --non-interactive to skip pauses:
go run ./examples/events/telegram/ --non-interactive