MCP channel notifications (notifications/claude/channel) not surfaced to conversation

Status Closed — not planned
Maintainer reply ✓ Yes — localden
Activity 10 comments · opened Mar 21, 2026 · closed May 23, 2026
💡 Likely answer: A maintainer (localden, collaborator) responded on this thread — see the highlighted reply below.

Description

MCP server-to-client notifications using the notifications/claude/channel method are successfully sent by the MCP server but never surfaced as <channel> tags in the conversation.

Environment

  • macOS (Darwin 25.3.0)
  • Claude Code (latest as of 2026-03-21)
  • Telegram channel plugin (claude-plugins-official/telegram v0.0.1)

Steps to Reproduce

  1. Install and configure the Telegram channel plugin
  2. Pair a Telegram user via /telegram:access pair <code>
  3. Send a message from the paired Telegram user to the bot

Expected Behavior

The message should appear in the Claude Code conversation as a <channel source="telegram" ...> tag, allowing the assistant to see and respond to it.

Actual Behavior

The message never appears. The assistant receives no <channel> tag.

Debugging Performed

  • MCP tool calls work finereply, react, edit_message tools all function correctly (request/response path is healthy)
  • Bot is polling Telegram — confirmed via active TCP connections to Telegram API servers and pending_update_count: 0
  • Gate check passes — sender is in allowFrom, gate returns deliver
  • mcp.notification() succeeds — added debug logging to server.ts confirming:

``
2026-03-21T06:18:05.507Z handleInbound called: text="Hi" from=126616686
2026-03-21T06:18:05.508Z gate result: deliver
2026-03-21T06:18:05.509Z sending mcp.notification for chat_id=126616686
2026-03-21T06:18:05.510Z mcp.notification sent successfully
``

  • MCP stdio pipes are connected — verified via lsof showing unix socket FDs for stdin/stdout/stderr between Claude Code and the bun server process
  • The experimental capability is advertised — server declares experimental: { 'claude/channel': {} } in capabilities

Conclusion

The MCP server correctly sends notifications/claude/channel notifications over stdio. Claude Code receives them (no error thrown) but does not inject them as <channel> tags into the conversation. This was tested across multiple Claude Code restarts and a full plugin reinstall.

🤖 Generated with Claude Code

View original on GitHub ↗

10 Comments

jsj972 · 5 months ago

Also affected by this bug. Linux (Ubuntu) aarch64, Claude Code v2.1.83. Confirmed the plugin emits a well-formed JSON-RPC notification on stdout, but Claude Code never surfaces it as a <channel> tag. Outbound tools work fine. Closed my duplicate #38926 in favor of this one. 👍

ns3154 · 5 months ago

Confirmed with the official Discord plugin as well (discord@claude-plugins-official). In my case, the MCP tools work normally (reply, etc.), the bot is online, and when I run the plugin server manually I can confirm it emits a well-formed notifications/claude/channel notification for inbound DMs. However, in hosted Claude Code sessions, that notification is never surfaced as a <channel source="discord" ...> block. This matches the same core issue: standard MCP tools work, but experimental channel notifications are not injected into the conversation.

amrzaher027 · 5 months ago

Is there an ETA for resolution?

hibbes · 5 months ago

Also affected. Adding data from a non-Bun environment:

Environment

  • Gentoo Linux (kernel 6.18.12), x86_64
  • Claude Code CLI latest, started with --channels plugin:telegram@claude-plugins-official
  • Node.js v24.14.0, npx tsx v4.21.0
  • Bun cannot run on this system (requires AVX — not available on this CPU), so the plugin is patched to use npx tsx instead

Patches applied to plugin v0.0.4 (~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.4/)

  • server.ts shebang: #!/usr/bin/env bun#!/usr/bin/env npx tsx
  • package.json scripts.start: bun server.tsnpm install --silent && npx tsx server.ts
  • .mcp.json command: bunnpx --yes tsx
  • Added tsx and zod to dependencies, installed via npm install

Behavior

  • Plugin server starts (no errors on stderr)
  • telegram__reply outbound tool works correctly — messages arrive in Telegram
  • Inbound messages from Telegram are consumed by polling (getUpdates returns empty)
  • notifications/claude/channel never surfaces in the conversation
  • Bot appears online in Telegram but Claude never sees incoming messages

Confirms: This is NOT a bun-specific issue. The same behavior occurs with Node.js + tsx. The root cause is client-side — Claude Code does not process notifications/claude/channel from MCP channel servers.

Previously reported this as #37633 (closed as duplicate of this issue).

omusubiman5 · 5 months ago

Also affected. Windows 11, Claude Code v2.1.90, Discord plugin (discord@claude-plugins-official).

Confirmed behavior:

  • Plugin loads, Listening for channel messages displayed
  • Bot connects to Discord Gateway successfully (gateway connected as Klaude Code Channels#6759)
  • MCP initialize handshake succeeds, capabilities include claude/channel and claude/channel/permission
  • Bot token valid, access.json properly configured with allowlist
  • Outbound tools (reply, etc.) work
  • Inbound notifications/claude/channel never surfaced to conversation

Additional finding: During troubleshooting, discovered that plugin cache directory was silently emptied after a Claude Code update (plugin files deleted but installed_plugins.json still references the path). This may be a separate plugin cache migration bug on Windows. Had to manually restore files from marketplace source to get the plugin running at all.

Verified not a plugin issue: Running the plugin manually with MCP init over stdin confirms it emits well-formed notifications/claude/channel JSON-RPC notifications. The problem is entirely client-side — Claude Code receives but does not inject them.

This has been open for 2 weeks with no response from Anthropic. Multiple users across Mac, Linux, and Windows are affected. Please prioritize.

hibbes · 4 months ago

Full workaround: standalone polling daemon with auto-reply (Linux)

Since this bug is in Claude Code's core notification handler and can't be fixed externally, I built a standalone replacement that completely bypasses the broken plugin. Handles inbound (polling + inbox files) and outbound (auto-reply via claude -p). Running in production on Gentoo Linux.

Architecture

Instead of relying on the plugin's notifications/claude/channel (silently dropped by Claude Code), this uses:

  1. Direct Bot API polling via getUpdates (every 3s)
  2. Allowlist filtering by sender ID
  3. Inbox file writing (.txt for text, .jpg for photos, documents by name) to ~/.claude/channels/telegram/inbox/
  4. Auto-reply via claude -p --model haiku — response sent back as a Telegram reply
  5. Outbound helper (klaus-send "text") for manual messages
  6. Runs as an OpenRC service (adaptable to systemd)

The polling script

#!/usr/bin/env bash
# Telegram Poller — workaround for broken Claude Code channel notifications
# (anthropics/claude-code#36975, #37933)
#
# The official plugin sends mcp.notification(notifications/claude/channel)
# correctly, but Claude Code's host process silently drops it. This script
# bypasses the plugin: polls Bot API directly, saves inbound messages to
# inbox, and auto-replies via claude -p.
#
# Requirements: curl, jq, claude CLI in PATH
# Do NOT run alongside the Telegram plugin (polling conflict / 409 errors).

set -euo pipefail

# --- Configuration -----------------------------------------------------------
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_ALLOWED_SENDER_ID"        # Allowlist of one
API="https://api.telegram.org/bot${BOT_TOKEN}"
INBOX="$HOME/.claude/channels/telegram/inbox"
OFFSET_FILE="$HOME/.claude/channels/telegram/.poll_offset"
POLL_INTERVAL=3                          # Seconds between getUpdates calls

# System prompt for auto-replies (claude -p)
SYSTEM_PROMPT="Your system prompt here — keep it short for Telegram."

# --- Setup -------------------------------------------------------------------
mkdir -p "$INBOX"

# Resume from last processed update to avoid duplicates across restarts
offset=0
[[ -f "$OFFSET_FILE" ]] && offset=$(<"$OFFSET_FILE")

log() { echo "[$(date '+%H:%M:%S')] $*"; }
cleanup() { log "Shutting down"; exit 0; }
trap cleanup SIGTERM SIGINT

log "Telegram Poller started (offset=$offset)"

# --- Main loop ---------------------------------------------------------------
while true; do
    # Long-poll with 2s timeout so we're not hammering the API
    response=$(curl -sf --max-time 10 \
        "${API}/getUpdates?offset=${offset}&timeout=2&allowed_updates=%5B%22message%22%5D" \
        2>/dev/null) || {
        log "API request failed, retrying..."
        sleep "$POLL_INTERVAL"
        continue
    }

    count=$(echo "$response" | jq '.result | length')

    for ((i = 0; i < count; i++)); do
        update=$(echo "$response" | jq ".result[$i]")
        update_id=$(echo "$update" | jq '.update_id')
        sender_id=$(echo "$update" | jq -r '.message.from.id // empty')

        # --- Allowlist check -------------------------------------------------
        if [[ "$sender_id" != "$CHAT_ID" ]]; then
            log "Ignored message from $sender_id"
            offset=$((update_id + 1))
            echo "$offset" > "$OFFSET_FILE"
            continue
        fi

        msg=$(echo "$update" | jq '.message')
        msg_id=$(echo "$msg" | jq '.message_id')
        timestamp=$(date '+%Y%m%d-%H%M%S')
        text=$(echo "$msg" | jq -r '.text // .caption // empty')

        # --- Save photos (highest resolution) --------------------------------
        photo_file=$(echo "$msg" | jq -r '.photo[-1].file_id // empty')
        if [[ -n "$photo_file" ]]; then
            file_info=$(curl -sf "${API}/getFile?file_id=${photo_file}" 2>/dev/null)
            file_path=$(echo "$file_info" | jq -r '.result.file_path // empty')
            if [[ -n "$file_path" ]]; then
                photo_out="${INBOX}/${timestamp}_${msg_id}.jpg"
                curl -sf "https://api.telegram.org/file/bot${BOT_TOKEN}/${file_path}" \
                    -o "$photo_out" 2>/dev/null
                log "Photo saved: $photo_out"
            fi
        fi

        # --- Save documents --------------------------------------------------
        doc_file=$(echo "$msg" | jq -r '.document.file_id // empty')
        if [[ -n "$doc_file" ]]; then
            doc_name=$(echo "$msg" | jq -r '.document.file_name // "document"')
            file_info=$(curl -sf "${API}/getFile?file_id=${doc_file}" 2>/dev/null)
            file_path=$(echo "$file_info" | jq -r '.result.file_path // empty')
            if [[ -n "$file_path" ]]; then
                doc_out="${INBOX}/${timestamp}_${msg_id}_${doc_name}"
                curl -sf "https://api.telegram.org/file/bot${BOT_TOKEN}/${file_path}" \
                    -o "$doc_out" 2>/dev/null
                log "Document saved: $doc_out"
            fi
        fi

        # --- Save text to inbox ----------------------------------------------
        if [[ -n "$text" ]]; then
            echo "$text" > "${INBOX}/${timestamp}_${msg_id}.txt"
            log "Message saved: ${timestamp}_${msg_id}.txt"
        fi

        # --- Auto-reply via Claude -------------------------------------------
        if [[ -n "$text" ]]; then
            # Show typing indicator while Claude generates
            curl -sf -X POST "${API}/sendChatAction" \
                -H 'Content-Type: application/json' \
                -d "{\"chat_id\":${CHAT_ID},\"action\":\"typing\"}" \
                >/dev/null 2>&1 || true

            # Generate reply (haiku for speed, swap to sonnet/opus for quality)
            reply=$(claude -p --model haiku "${SYSTEM_PROMPT}

Message: ${text}" 2>/dev/null) || reply=""

            if [[ -n "$reply" ]]; then
                reply="${reply:0:4096}"  # Telegram message length limit
                curl -sf -X POST "${API}/sendMessage" \
                    -H 'Content-Type: application/json' \
                    -d "{\"chat_id\":${CHAT_ID},\"text\":$(echo "$reply" | jq -Rs .),\"reply_to_message_id\":${msg_id}}" \
                    >/dev/null 2>&1 || true
                log "Replied to msg $msg_id"
            else
                # Claude failed — ack with reaction
                curl -sf -X POST "${API}/setMessageReaction" \
                    -H 'Content-Type: application/json' \
                    -d "{\"chat_id\":${CHAT_ID},\"message_id\":${msg_id},\"reaction\":[{\"type\":\"emoji\",\"emoji\":\"👀\"}]}" \
                    >/dev/null 2>&1 || true
                log "Claude failed, ack-only for msg $msg_id"
            fi
        else
            # Non-text (photo/doc only) — ack with reaction
            curl -sf -X POST "${API}/setMessageReaction" \
                -H 'Content-Type: application/json' \
                -d "{\"chat_id\":${CHAT_ID},\"message_id\":${msg_id},\"reaction\":[{\"type\":\"emoji\",\"emoji\":\"👀\"}]}" \
                >/dev/null 2>&1 || true
        fi

        # Advance offset so this update isn't re-processed
        offset=$((update_id + 1))
        echo "$offset" > "$OFFSET_FILE"
    done

    sleep "$POLL_INTERVAL"
done

Outbound helper

#!/usr/bin/env bash
# Send a message to Telegram via Bot API
set -euo pipefail
BOT_TOKEN="YOUR_BOT_TOKEN"
CHAT_ID="YOUR_SENDER_ID"
[[ $# -eq 0 ]] && { echo "Usage: klaus-send <message>"; exit 1; }
curl -sf -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
    -H 'Content-Type: application/json' \
    -d "{\"chat_id\":${CHAT_ID},\"text\":$(echo "$*" | jq -Rs .),\"parse_mode\":\"Markdown\"}" \
    | jq -r '"Sent (msg_id: \(.result.message_id))"'

OpenRC service

#!/sbin/openrc-run
description="Telegram Bot Poller"
command="/usr/local/bin/klaus-telegram"
command_user="youruser:youruser"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.log"

depend() { after net; }

start_pre() { checkpath -f -m 0644 -o youruser:youruser "$output_log"; }

For systemd: Type=simple, User=youruser, ExecStart=/usr/local/bin/klaus-telegram, Restart=always.

What this replaces

| Feature | Broken Plugin | This Workaround |
|---------|--------------|-----------------|
| Inbound text | silently dropped | saved to inbox + auto-reply |
| Inbound photos | dropped | downloaded to inbox |
| Outbound messages | via MCP tools | via sendMessage helper |
| Auto-reply | never triggered | via claude -p |
| Typing indicator | no | yes |
| Allowlist | via access.json | via sender ID check |

Important

  • Do NOT run alongside the Telegram plugin — two pollers on the same token cause 409 conflicts and lost messages. Start Claude Code without --channels plugin:telegram@claude-plugins-official.
  • Offset persists across restarts (no duplicate messages).
  • Requirements: curl, jq, claude CLI.

See also #37933 for more reports and discussion of this issue.

Environment: Gentoo Linux, Claude Code 2.1.101, bash, curl, jq.

hibbes · 4 months ago

Follow-up to my 2026-04-11 workaround comment — the framing was off

In my earlier comment here I attributed the dropped notifications/claude/channel to a host-side gap in the Channel framework and shipped a standalone polling daemon as workaround. Today I ran the plugin end-to-end on two different setups and the root cause in my specific failure mode is simpler than I thought — at least for me.

The host delivers notifications correctly. In my case the plugin's MCP server was never spawning in the first place.

What I tested today

Same plugin (telegram@claude-plugins-official v0.0.6), same bot, two hosts:

  1. HP ProDesk 600 G3 mini, Gentoo, i3-7100T (AVX2). Stock bun install -g, native Claude Code binary, claude --channels plugin:telegram@claude-plugins-official. Paired on the first try, DM surfaced as a ← telegram · … prompt, reply tool call round-tripped. Plugin works out of the box as documented.
  1. Core2Duo P7350, no AVX / no SSE4.2, Claude Code running under Node via the extracted-JS hack that also addresses #37065. bun is not installable here — both the regular build and bun-linux-x64-baseline SIGILL because baseline still effectively needs x86-64-v2 (SSE4.2 + POPCNT) and Penryn only has SSE4.1. I shadowed the plugin's .mcp.json with a tiny node --import tsx/esm server.ts wrapper (deps via npm install in the plugin dir). The MCP log then showed:

``
Starting connection with timeout of 30000ms
Successfully connected (transport: stdio) in 6868ms
Connection established with capabilities: {"hasTools":true, … ,"serverVersion":{"name":"telegram","version":"1.0.0"}}
Channel notifications registered
notifications/claude/channel: Test2
notifications/claude/channel: Hallo!
Calling MCP tool: reply
Tool 'reply' completed successfully in 105ms
``

Both DMs surfaced in the Claude session, reply landed back to the user on Telegram. So the Node-hosted Claude Code processes notifications/claude/channel correctly — when the server actually runs.

Where the silent failure comes from

The plugin's .mcp.json hard-requires bun:

"command": "bun",
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"]

If bun isn't on PATH, or SIGILLs on the CPU (same population affected by #37065), the MCP server never starts. The host's connection attempt fails, but on my side the only user-visible effect was "DMs vanish into thin air" — easy to mis-diagnose as a framework issue, which is exactly what I did back in April.

First-check that pins this down for anyone else hitting my symptom: look for ERR_MODULE_NOT_FOUND / command not found / exit-on-connect in ~/.cache/claude-cli-nodejs/<project>/mcp-logs-plugin-telegram-telegram/*.jsonl. If the very first log for a session is Connection failed after Xms: MCP error -32000: Connection closed with nothing in between, the server just isn't spawning.

Workaround if bun is off the table

Small wrapper in the plugin cache — survives until the next plugin update:

cd ~/.claude/plugins/cache/claude-plugins-official/telegram/<version>
npm install --no-audit --no-fund          # fetches grammy + @modelcontextprotocol/sdk
npm install --no-audit --no-fund tsx
cat > run-server.sh <<'SH'
#!/bin/bash
cd "$(dirname "$(readlink -f "$0")")"
exec node --import "./node_modules/tsx/dist/esm/index.mjs" "./server.ts"
SH
chmod +x run-server.sh

And .mcp.json:

{ "mcpServers": { "telegram": { "command": "${CLAUDE_PLUGIN_ROOT}/run-server.sh" } } }

A cleaner upstream fix would be a fallback runtime in .mcp.json (try bun, fall back to node --import tsx/esm) or shipping a precompiled server.mjs alongside server.ts.

Caveats

This only explains my failure mode. If your host has a working bun and you still see DMs disappear — e.g. #36503 (macOS, bun almost certainly present) or @Lucasafaria1's WhatsApp-plugin-with-groups case — then this observation doesn't apply and the original framework-bug hypothesis may well still hold. Worth checking the same MCP log first, though.

The standalone polling daemon from my earlier comment is still useful as a permanent Telegram bridge that survives session restarts (the plugin only lives while a --channels session is open), but framing it as the fix for a framework bug was wrong on my part.

tommohide-kumekawa · 4 months ago

Resolved on macOS — confirms @hibbes' 2026-04-23 follow-up, with a separate parsing bug

Confirming the framework-bug framing is wrong on macOS as well. The "notifications never surface" symptom traces back to the same root cause @hibbes identified — --channels opt-in not being passed at startup — but reaches it via a different path than their bun/SIGILL case. Posting in case anyone else is still stuck on this thread.

Environment

  • macOS 15.x (Darwin 25.3.0)
  • Claude Code v2.1.121
  • imessage@claude-plugins-official v0.1.0, discord@claude-plugins-official v0.0.4
  • bun installed and working — so the bun-SIGILL / missing-bun path from the 2026-04-23 comment doesn't apply

Diagnosis

Plain claude startup. MCP log at ~/Library/Caches/claude-cli-nodejs/<project>/mcp-logs-plugin-imessage-imessage/*.jsonl:

\\\
Successfully connected (transport: stdio) in 213ms
Connection established with capabilities: {"hasTools":true,...,"serverVersion":{"name":"imessage","version":"1.0.0"}}
Channel notifications skipped: server plugin:imessage:imessage not in --channels list for this session
\
\\

The plugin's MCP server is healthy. Outbound tools (reply etc.) work fine. Channel notifications are deliberately skipped because --channels was not passed at startup. Same for the Discord plugin in the parallel log directory.

Resolution

Start Claude Code with explicit --channels opt-in for each plugin you want inbound notifications from:

\\\bash
claude --channels plugin:imessage@claude-plugins-official --channels plugin:discord@claude-plugins-official
\
\\

After this, the startup banner shows:

\\\
Listening for channel messages from: plugin:imessage@claude-plugins-official, plugin:discord@claude-plugins-official
\
\\

<channel source="imessage" ...> / <channel source="discord" ...> blocks now surface in the session for inbound DMs, and the Channel notifications skipped line is gone from the MCP log. Verified end-to-end on both plugins.

Secondary bug — comma-separated --channels entries silently fail

The first form I tried — comma-joining the entries the way --allowedTools etc. accept — is broken:

\\\bash
claude --channels plugin:imessage@claude-plugins-official,plugin:discord@claude-plugins-official
\
\\

Claude Code prints:

\\\
Listening for channel messages from: plugin:imessage@claude-plugins-official,plugin:discord@claude-plugins-official
plugin:imessage@claude-plugins-official,plugin:discord@claude-plugins-official · plugin not installed
plugin:imessage@claude-plugins-official,plugin:discord@claude-plugins-official · not on the approved channels allowlist
you asked for plugin:imessage@claude-plugins-official,plugin:discord@claude-plugins-official but the installed imessage plugin is from claude-plugins-official
\
\\

i.e. the comma-joined string is treated as a single plugin ID rather than being split into entries. Repeating --channels per entry is the only working form I found. Worth noting the inconsistency: the validation error for an untagged entry does say \"--channels entries must be tagged\" (plural \"entries\") and joins them with commas in its own output, so the parser intent looks split-on-comma somewhere internally.

Doc gap

claude --help on v2.1.121 does not list --channels at all. Users who don't follow this issue thread or read MCP source won't know the flag exists. The failure mode is hard to debug without going into mcp-logs-*/*.jsonl — would help to surface in --help and/or claude doctor.

Thanks @hibbes for the 2026-04-23 follow-up — pointed me at the right log file.

localden collaborator · 3 months ago

Thanks for the report. This is the same issue as #36431 (notifications/claude/channel from stdio MCP servers is acknowledged but never delivered into the conversation). Consolidating tracking there. Please follow #36431 for updates.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.