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.md for 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: null on the wire, no replay buffer.
  • The typed Go SDK at experimental/ext/events/clients/go/ consuming the same Stream() / Subscribe() helpers as discord, just with a different Data shape.
  • A /inject POST endpoint (wired via server.WithMux) that synthesizes events for the test-mode walkthrough — same convention as discord-events.

Where to look in the code

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

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