Channels reply toolを実装する — Claude Codeで二方向チャネルを作る
Claude Code Channelsを双方向化するreply toolの実装手順。capabilities.tools宣言からClaudeへの返信ルーティング、curlでの動作確認までコード付きでまとめました。
Channels reply toolとは何か
Claude Code Channelsは、外部のイベントを実行中のセッションへpushする仕組みです。CIの失敗通知のような一方向のアラートなら、イベントを流し込むだけで完結します。ですがTelegramやDiscordのようなチャットボットを作るなら、Claudeが返事を送り返す経路が要ります。それがreply toolです。
reply toolの正体は特別なものではありません。標準のMCP toolです。Channel固有の拡張は「メッセージを受け取る」側の claude/channel capabilityにあり(有効化手順と落とし穴はMCPサーバーのChannels連携を実装するで扱っています)、「返事を送る」側は素のMCPツール登録の作法をそのまま使います。この記事では、Channels referenceのwebhookサーバー例をベースに、reply toolを追加する3ステップを実装します。
前提条件:
- Claude Codeがclaude.aiアカウントまたはConsole APIキーでログイン済み(ChannelsはAmazon Bedrock・Google Cloud's Agent Platform・Microsoft Foundryでは使えません)
- Bunがインストール済み(
bun --versionで確認) - Team / Enterprise組織、またはmanaged settingsを配布しているConsole組織の場合は、管理者が
channelsEnabledを有効化済み - 一方向のchannelサーバー(
claude/channelcapabilityを宣言したMCPサーバー)がすでに動いている。ゼロから作る場合はMCPサーバー自作ガイドでSDKの基本を、Claude Code ChannelsでWebhook受信サーバーを自作するで送信元をゲートする最小構成を先に押さえておくと読みやすくなります
Channelsは研究プレビューの機能です。--channelsフラグの構文やプロトコル契約は今後変わる可能性があります。v2.1.289でclaude --helpの出力を絞り込むと、フラグが一覧に出ないことが分かります。
claude --version
# 2.1.289 (Claude Code)
claude --help | grep -i -E "channels|dangerously-load"
# (出力なし)grepの行には何も出ません。ヘルプに載っていなくてもフラグは使えるので、「一覧に無い = 未対応」とは読まないでください。
ツール検出を有効にする
reply toolを追加する前に、Claude Codeに「このサーバーはツールを持っている」と伝える必要があります。Serverコンストラクタのcapabilitiesにtools: {}を足すだけです。
capabilities: {
experimental: { 'claude/channel': {} },
tools: {}, // これが無いとreply toolは検出されない
},2つの宣言の役割
claude/channel
Claude Code固有の拡張です。宣言があると通知のリスナーが登録され、notifications/claude/channelが<channel>タグとしてClaudeに届きます。
tools
標準のMCP宣言です。これでreplyツールが検出されます。一方向のアラート転送だけなら省略したままで構いません。
reply toolを登録する
ツール検出を有効にしたら、実際のツール定義とハンドラーを書きます。必要なのは2つのリクエストハンドラーです。
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
// Claudeが起動時に「どんなツールが使えるか」を問い合わせたときに返す
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'The conversation to reply in' },
text: { type: 'string', description: 'The message to send' },
},
required: ['chat_id', 'text'],
},
}],
}))
// Claudeが実際にreplyツールを呼び出したときに実行される
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
send(`Reply to ${chat_id}: ${text}`) // 実プラットフォームへのPOSTに置き換える
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})ListToolsRequestSchemaはツールのカタログを返すだけで、実際の送信処理はCallToolRequestSchema側にあります。send()の中身は本番ではチャットプラットフォームへのPOSTになります。動作確認中はSSE(Server-Sent Events)でブロードキャストしておくと、別ターミナルのcurl -Nで返信をリアルタイムに覗けます。
inputSchemaのchat_idとtextはTelegramやDiscordの命名に合わせているだけで、必須ではありません。相手プラットフォームの識別子に合わせて自由に設計できます。ただしフィールド名を変えたら、次のinstructions側の説明もそれに追従させる必要があります。
もう一つ押さえておきたいのは、Claude Codeは受け取ったイベントをack(受信確認)しないという点です。セッションがそのチャネルを読み込んでいない、または組織ポリシーで塞がれている場合、イベントは黙って捨てられ、サーバー側にエラーが返ることもありません。配送確認が必要なら、サーバー側で送信状態を持ち、Claudeが呼べるreply toolでその状態を報告させる設計にする必要があります。
instructionsで返信のルーティングを教える
Serverコンストラクタのinstructionsは、サーバーが接続した瞬間にClaudeへコンテキストとして渡される文字列です。ここで「どのイベントがreply対象か」「どのタグ属性をreply toolに渡すべきか」を明示しないと、Claudeは受信メッセージのどの情報を使って返信すればいいか判断できません。
instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'ポイントは、受信イベントの<channel>タグに付けたmeta属性(たとえばchat_id)と、reply toolの引数名をinstructionsの中で明示的に結びつけることです。これを書き忘れると、Claudeは会話をどこに返せばいいか推測するしかなく、誤った宛先に送る・返信自体をスキップするといった挙動になります。
metaのキーには制約があります。使えるのは英字・数字・アンダースコアだけで、ハイフンなどを含むキーは黙って捨てられます。chat-idと書いてしまうと、タグにchat_idが付かず、Claudeは返信先を知らないままになります。ここでもエラーは返りません。
複数の通知がClaudeの作業中に届いた場合は、次のターンにまとめて渡され、Claudeはグループとして処理します。chat_idが通知ごとに付いていれば、まとめて届いても返信先を取り違えにくくなります。
動作確認 — curlとSSEで往復を見る
ここまでの3ステップを、Channels referenceのwebhook例に足すと、以下のような完全なサーバーになります。
reply tool付きwebhook.tsの全体像
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const listeners = new Set<(chunk: string) => void>()
function send(text: string) {
const chunk = text.split('\n').map(l => `data: ${l}\n`).join('') + '\n'
for (const emit of listeners) emit(chunk)
}
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.',
},
)
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'The conversation to reply in' },
text: { type: 'string', description: 'The message to send' },
},
required: ['chat_id', 'text'],
},
}],
}))
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
send(`Reply to ${chat_id}: ${text}`)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})
await mcp.connect(new StdioServerTransport())
let nextId = 1
Bun.serve({
port: 8788,
hostname: '127.0.0.1',
idleTimeout: 0,
async fetch(req) {
const url = new URL(req.url)
if (req.method === 'GET' && url.pathname === '/events') {
const stream = new ReadableStream({
start(ctrl) {
ctrl.enqueue(': connected\n\n')
const emit = (chunk: string) => ctrl.enqueue(chunk)
listeners.add(emit)
req.signal.addEventListener('abort', () => listeners.delete(emit))
},
})
return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' } })
}
const body = await req.text()
const chat_id = String(nextId++)
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: body, meta: { chat_id, path: url.pathname, method: req.method } },
})
return new Response('ok')
},
})Claude Codeがサーバーを起動できるように、webhook.tsと同じディレクトリの.mcp.jsonに登録しておきます。ユーザー単位の~/.claude.jsonに書くときは絶対パスにします。
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}研究プレビュー中は独自チャネルが承認リストに載っていないため、--dangerously-load-development-channelsで起動します。--channelsと同時に指定しても、後者のエントリまでは承認リストの確認を免除されません。
claude --dangerously-load-development-channels server:webhook別ターミナルでSSEストリームを購読し、Claudeの返信を待ち受けます。
curl -N localhost:8788/events3つ目のターミナルからメッセージをPOSTします。
curl -X POST localhost:8788 -d "what's in my working directory?"起動時には、開発用チャネルを読み込むことを知らせる全画面の警告が出ます。「I am using this for local development」を選ぶと進めます。そのプロジェクトで初めて起動するときは、.mcp.jsonのサーバーについて「New MCP server found in this project: webhook」という同意の確認も出ます。登録できると、バナーの下に薄い表示が出ます。Channels (experimental) messages from server:webhook inject directly in this sessionで始まる一文です。
POSTした1通は、次の順で往復します。
1通のメッセージが往復する流れ
- 1
サーバーがchat_idを振って通知する
nextIdの連番がchat_idになり、notifications/claude/channelのmetaに載ります。端末には← webhook: what's in my working directory?という1行の要約が出ます。 - 2
Claudeがreplyを呼ぶ
instructionsの指示どおり、タグのchat_idを引数に渡してreplyを呼びます。初回は許可プロンプトが出ることがあります。 - 3
SSEに流れる
send()が購読中の全リスナーへ書き込み、/eventsを見ているターミナルにReply to 1: ...という行が現れます。
よくあるつまずき
reply toolを足した途端にサーバーが起動しなくなる
ListToolsRequestSchemaやCallToolRequestSchemaのimportを書き足した直後は、依存パッケージの追加漏れやタイプミスでサーバー自体が落ちていないかをまず疑います。セッション内で/mcpコマンドを実行するとサーバーごとの状態が確認でき、failedは依存関係やimportエラーを示すことが多いと公式は説明しています。stderrのトレースを見るには、--debug付きで起動し直します。
claude --debug --dangerously-load-development-channels server:webhookトレースは~/.claude/debug/<session-id>.txtに残ります。
curlは通るのにチャネルとして登録されない
MCPクライアントがv2ランタイムのとき、プロトコルのリビジョン2026-07-28を交渉したチャネルサーバーは登録されません。そのリビジョンはチャネルのメッセージを運べないためです。MCP_PROTOCOL_NEGOTIATION=autoにすると、Claude Codeはstdioサーバーにもそのリビジョンを問い合わせます。v2.1.285以降では、機能フラグを取得するセッションで既定でも問い合わせる方向にAnthropicが切り替えを進めています。自作のstdioチャネルが登録されないときは、MCP_PROTOCOL_NEGOTIATION=legacyで起動して挙動が変わるかを試します。legacyは全サーバーを従来のハンドシェイクのまま接続し、v2.1.221以降で使えます。MCP_SDK_GENERATION=v1でランタイム自体を固定する方法もあります。
toolsの宣言を忘れてListToolsRequestSchemaだけ足す
capabilities.toolsが無いままハンドラーだけ登録しても、Claude Codeはこのサーバーにツールがあることを検出しません。ツールを足したら必ずcapabilitiesの両方を確認します。
instructionsにchat_idの由来を書いていない
reply toolのinputSchemaにchat_idを用意しても、instructionsで「受信タグのどの属性から取るか」を教えないと、Claudeが正しい宛先を選べないことがあります。ツールのスキーマとinstructionsは常にセットで更新します。
永続プロセスが無いのに常時応答を期待する
Channelsのイベントはセッションが開いている間しか届きません。常時稼働の返信ボットにするなら、バックグラウンドプロセスか常駐ターミナルでClaude Codeを走らせ続ける必要があります。
curlがconnection refusedで落ちる
ポートが未bindか、前回起動したサーバーの残留プロセスがそのポートを掴んだままになっていることが多いです。lsof -i :<port>で該当プロセスを確認し、killしてからセッションを起動し直します。
組織ポリシーで止められている
起動時に「blocked by org policy」と出るときは、組織の管理者がチャネルを有効にしていません。channelsEnabledが無効または未設定だと、開発用フラグを付けてもチャネルのメッセージは届きません。MCPサーバー自体は接続され、reply toolなどのツールも動きます。起動時には、管理者に設定を有効にしてもらうよう促す警告が出ます。
公開エンドポイントに認証なしでreplyを晒す
この記事のサンプルはローカル確認用で送信元を検証していません。実プラットフォームに繋ぐ前に、受信メッセージの送信者を必ず検証します。手順はChannelsの受信メッセージをゲートする設計にまとめました。
reply toolの先にある権限中継
reply toolまで実装すると、Claudeは会話の往復ができるようになります。次に欲しくなるのは、Bashやファイル編集の承認プロンプトも同じチャットへ転送することです。これはreply toolとは別のcapability(claude/channel/permission)で宣言する機能です。
端末のダイアログと遠隔の両方が開いたままになり、先に届いた答えが採用されます。中継の対象はBashやWrite、Editなどのツール実行の承認で、プロジェクトの信頼確認やMCPサーバーの同意ダイアログは端末にしか出ません。実装手順はChannelsで権限確認プロンプトをチャット側に中継するにあります。
Channelsの--channelsフラグは2.1.80で研究プレビューとして追加され、権限中継は2.1.81で加わりました。reply toolは標準MCPのツール登録をそのまま使うので、変化があったのは中継まわりのセキュリティ強化です。v2.1.211では、中継する承認メッセージの双方向の向きを変える文字・ゼロ幅文字・見間違えやすい引用符が無害化されました。v2.1.234では、承認済みのチャネルサーバーにだけ中継し、サーバー側の明示的なオプトアウトも尊重します。
自作したチャネルをチームに配るには、プラグイン化して配布する経路もあります。ただし、自社マーケットプレイスで配っても、Anthropicが管理する承認リストには載りません。Team / Enterpriseの管理者がmanaged settingsのallowedChannelPluginsに自社のプラグインを指定すれば、--channelsで登録できます。それ以外の環境では--dangerously-load-development-channelsが必要です。
まとめ
返信が動かないときは、宣言・ハンドラー・instructionsの3点より先に、チャネルが登録されているか(起動時の表示と/mcp)とmetaのキー名を確かめるのが近道です。実プラットフォームに繋ぐ前には、送信元の検証を済ませておきます。
よくある質問
reply toolの名前はreply固定ですか
固定ではありません。ListToolsRequestSchemaのnameフィールドで自由に設定できます。instructions側で同じ名前を参照してさえいれば、send_messageのような別名でも動作します。
Node.jsやDenoでも同じ実装で動きますか
動きます。channelサーバーの要件は@modelcontextprotocol/sdkとNode.js互換ランタイムだけで、Bunに限定されません。研究プレビューの公式プラグインはBunを使っていますが、reply toolの実装自体はランタイム非依存です。