Claude Media
Channels reply toolを実装する — Claude Codeで二方向チャネルを作る

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がインストール済み(で確認)
  • Team / Enterprise組織の場合は管理者が channelsEnabled を有効化済み
  • 一方向のchannelサーバー(claude/channel capabilityを宣言したMCPサーバー)がすでに動いている。ゼロから作る場合はMCPサーバー自作ガイドでSDKの基本を、Claude Code ChannelsでWebhook受信サーバーを自作するで送信元をゲートする最小構成を先に押さえておくと読みやすくなります

Channelsは研究プレビューの機能です。--channelsフラグの構文やプロトコル契約は今後変わる可能性があり、claude --helpにも表示されません。

ツール検出を有効にする

reply toolを追加する前に、Claude Codeに「このサーバーはツールを持っている」と伝える必要があります。Serverコンストラクタのcapabilitiestools: {}を足すだけです。

capabilities: {
  experimental: { 'claude/channel': {} },
  tools: {},  // これが無いとreply toolは検出されない
},

claude/channelが「イベントを受け取る」ための宣言、toolsが「ツールを提供する」ための標準MCP宣言です。両方揃って初めて二方向のチャネルになります。一方向のアラート転送だけで済むサーバーはtoolsを省略したままで構いません。

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で返信をリアルタイムに覗けます。

inputSchemachat_idtextは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は会話をどこに返せばいいか推測するしかなく、誤った宛先に送る・返信自体をスキップするといった挙動になります。

動作確認 — curlとSSEで往復を見る

ここまでの3ステップを、Channels referenceのwebhook例に足すと、以下のような完全なサーバーになります。

reply tool付きwebhook.tsの全体像
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')
  },
})

研究プレビュー中は独自チャネルが承認リストに載っていないため、--dangerously-load-development-channelsで起動します。

claude --dangerously-load-development-channels server:webhook

別ターミナルでSSEストリームを購読し、Claudeの返信を待ち受けます。

curl -N localhost:8788/events

3つ目のターミナルからメッセージをPOSTすると、Claudeがreply toolを呼び出すまでの一連の流れを確認できます。

curl -X POST localhost:8788 -d "what's in my working directory?"

Claudeが初めてreplyツールを呼ぶとき、ターミナルに許可プロンプトが出ることがあります。承認すると、/eventsをwatchしているターミナルにReply to 1: ...という行が流れてきます。

よくあるつまずき

reply toolを足した途端にサーバーが起動しなくなる

ListToolsRequestSchemaCallToolRequestSchemaのimportを書き足した直後は、依存パッケージの追加漏れやタイプミスでサーバー自体が落ちていないかをまず疑います。セッション内で/mcpコマンドを実行するとサーバーごとの状態が確認でき、failed表示は依存関係やimportエラーが原因であることが大半です。原因の特定には~/.claude/debug/<session-id>.txtに残るstderrトレースが役立ちます。

toolsの宣言を忘れてListToolsRequestSchemaだけ足す

capabilities.toolsが無いままハンドラーだけ登録しても、Claude Codeはこのサーバーにツールがあることを検出しません。ツールを足したら必ずcapabilitiesの両方を確認します。

instructionschat_idの由来を書いていない

reply toolのinputSchemachat_idを用意しても、instructionsで「受信タグのどの属性から取るか」を教えないと、Claudeが正しい宛先を選べないことがあります。ツールのスキーマとinstructionsは常にセットで更新します。

永続プロセスが無いのに常時応答を期待する

Channelsのイベントはセッションが開いている間しか届きません。常時稼働の返信ボットにするなら、バックグラウンドプロセスか常駐ターミナルでClaude Codeを走らせ続ける必要があります。

curlがconnection refusedで落ちる

ポートが未bindか、前回起動したサーバーの残留プロセスがそのポートを掴んだままになっていることが多いです。lsof -i :<port>で該当プロセスを確認し、killしてからclaude --dangerously-load-development-channelsを起動し直します。

公開エンドポイントに認証なしでreplyを晒す

この記事のサンプルはローカル確認用で送信元を検証していません。実プラットフォームに繋ぐ前に、受信メッセージの送信者を必ず検証します。手順はChannelsの受信メッセージをゲートする設計にまとめました。

reply toolの先にある権限中継

reply toolまで実装すると、Claudeは会話の往復ができるようになります。次に欲しくなるのは、Bashやファイル編集の承認プロンプトも同じチャットへ転送することです。これはreply toolとは別のcapability(claude/channel/permission)で宣言する機能です。実装手順はChannelsで権限確認プロンプトをチャット側に中継するで扱います。

Channelsの--channelsフラグは2.1.80で研究プレビューとして追加されました。reply toolの仕組み自体はこの初期リリースから変わっておらず、標準MCPのツール登録をそのまま使う設計は一貫しています。変化があったのは権限中継まわりのセキュリティ強化(v2.1.211 / v2.1.234)です。

自作したチャネルをチームに配るには、プラグイン化して配布する経路もあります。ただし自社マーケットプレイス経由でも研究プレビュー中は承認リストに載らないため、利用には引き続き--dangerously-load-development-channelsが必要です。

まとめ

二方向チャネルとして成立させるには、capabilities.toolsの宣言、ListToolsRequestSchema/CallToolRequestSchemaによるreply toolの実装、そしてinstructionsでの宛先ルーティングの明示という3点が揃っていれば十分です。ここまでを実装すれば、Claudeが受信イベントに対して能動的に返信できる状態になります。認証や常時稼働まわりは、実運用に載せる前に「よくあるつまずき」で挙げた項目を順に潰していくとよいでしょう。

よくある質問

reply toolの名前はreply固定ですか

固定ではありません。ListToolsRequestSchemanameフィールドで自由に設定できます。instructions側で同じ名前を参照してさえいれば、send_messageのような別名でも動作します。

一方向チャネルでもtoolsを宣言する必要がありますか

必要ありません。公式ドキュメントは、一方向チャネルを作るにはcapabilities.tools自体を省略すればよいとしています。ツールを提供する予定が無いなら、宣言しないのがシンプルです。

reply toolの呼び出しにも権限確認は必要ですか

はい。replyは標準のMCPツールなので、通常のMCPツール呼び出しと同じ権限確認の対象になります。手動モードでは初回呼び出し時にターミナルで承認を求められます。

Node.jsやDenoでも同じ実装で動きますか

動きます。channelサーバーの要件は@modelcontextprotocol/sdkとNode.js互換ランタイムだけで、Bunに限定されません。研究プレビューの公式プラグインはBunを使っていますが、reply toolの実装自体はランタイム非依存です。

この記事を共有:XはてブLinkedIn