
こんにちは。よっしーです(^^)
背景
この連載では、Claude Codeの公式ドキュメントを1ページずつ読み解いていきます。公式ドキュメントは情報が網羅されている分、「結局どの機能を、どんな場面で使えばいいのか」は自分で考える必要があり、読むのに意外と時間がかかります。そこで、私が実務で使うために読み込んだ内容を「使う場面→実例コード」の順に整理して残していくことにしました。専門家の解説というより、一次情報を読んだ記録です。推測や動作を確認していない部分には、その都度そう書きます。
1. 一言でいうと何か
第45回で扱ったチャネルは、Telegram や Discord といった既製プラグインを使う側の話でした。今回はその裏側——自分でチャネルサーバーを書く側のリファレンスです。
結論から言うと、**チャネルとは「claude/channel という機能を宣言し、notifications/claude/channel イベントを発行する MCP サーバー」**です。それだけです。特別なフレームワークも独自プロトコルもありません。MCP の標準的な仕組みの上に、Claude Code 拡張のメソッドが2〜3個載っているだけという構成になっています。
サーバーは Claude Code と同じマシン上で動き、Claude Code がサブプロセスとして生成し、stdio 経由で通信します。この「同じマシン・サブプロセス・stdio」という前提が、外部システムとの繋ぎ方を2種類に分けます。
- チャットプラットフォーム(Telegram、Discord):プラグインがローカルで動き、プラットフォームの API をポーリングして新着メッセージを取る。誰かがボットに DM すると、プラグインがそれを受け取って Claude に転送する。公開する URL は不要。
- Webhook(CI、監視):サーバーがローカルの HTTP ポートでリッスンする。外部システムがそこに POST し、サーバーがペイロードを Claude にプッシュする。
作れるチャネルは3段階あり、段階ごとに実装が増えていきます。
- 一方向:アラートや webhook を Claude に流すだけ。最小構成。
- 双方向:返信ツールを公開して、Claude がメッセージを返送できるようにする。チャットブリッジ。
- 権限リレー付き:ツール承認プロンプトをリモートに転送して、電話から承認・拒否できるようにする。ただし信頼できる送信者パスを持つチャネルに限る。
必要なものは驚くほど少なく、ハード要件は @modelcontextprotocol/sdk パッケージと Node.js 互換ランタイムだけ。Bun、Node、Deno のいずれでも動きます(リサーチプレビューの既製プラグインは Bun を使っていますが、チャネルが Bun である必要はありません)。
2. どういう場面で役立つか
シーン1:社内システムからイベントを流し込む
既製プラグインがあるのは Telegram / Discord / iMessage / fakechat だけです。社内の監視基盤、独自のチケットシステム、自作のデプロイパイプライン——プラグインが存在しないシステムと繋ぎたいなら、自分で書くことになります。そして書く量は、後述のとおり30行程度です。
シーン2:CI の失敗を、作業中のセッションに直接届ける
第45回で挙げたユースケースの実装がこれです。ローカルポートで受けて Claude に流すだけなので、CI 側は curl で POST するだけで済みます。
シーン3:チャットブリッジを自社のツールで作る
社内チャットが Slack でも Mattermost でも、そのプラットフォームの API をポーリングして notifications/claude/channel を発行し、返信ツールで送り返せば、Telegram プラグインと同等のものが作れます。
シーン4:外出先から権限プロンプトに答える
権限リレーは、無人運転(--dangerously-skip-permissions)とは違う解を提供します。**プロンプトを飛ばすのではなく、手元に転送して人間が判断する。**セキュリティを落とさずに離席できます。
不要・向かないケース
- 既製プラグインで足りる場合。Telegram / Discord / iMessage が使えるなら、自作する理由はありません。
- 送信者を認証できないチャネルで権限リレーをやりたい。**やってはいけません。**公式が明言しています——チャネル経由で返信できる者は誰でもセッションのツール使用を承認・拒否できるので、送信者認証があるチャネルでのみ機能を宣言すべきです。
- ゲートなしでパブリックなエンドポイントを開けたい。**ゲートなしチャネルはプロンプトインジェクションのベクトルです。**エンドポイントに到達できる誰もが Claude の前にテキストを置けます。
- 配信の確認を前提にした設計をしたい。通知は確認されません(後述)。
- 独立した複数のイベントストリームを同時に処理したい。イベントはセッションにキューされ順番に処理されます。並行処理が要るなら別セッションを立てるのが答えです。
- プロジェクト信頼や MCP サーバー同意ダイアログをリレーしたい。これらはリレーされません——ローカルターミナルにのみ表示されます。
- リサーチプレビュー中に、自作チャネルを普通に配布したい。カスタムチャネルは承認許可リストにないので、
--dangerously-load-development-channelsが必要です。
3. コードの実例と解説
サーバーが満たすべき3条件
まず全体像です。チャネルサーバーは次の3つをやれば成立します。
claude/channel機能を宣言する(これで Claude Code が通知リスナーを登録する)- 何か起きたら
notifications/claude/channelイベントを発行する - stdio トランスポート経由で接続する(Claude Code がサブプロセスとして生成する)
webhook レシーバーを作る
プロジェクトを作って SDK を入れます。
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
そして webhook.ts を書きます。これがチャネルサーバーの全体です。
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
// MCP サーバーを作成してチャネルとして宣言
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
// このキーがチャネルにする — Claude Code はそれのリスナーを登録
capabilities: { experimental: { 'claude/channel': {} } },
// Claude のシステムプロンプトに追加されるため、これらのイベントの処理方法を知っている
instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
},
)
// stdio 経由で Claude Code に接続(Claude Code はこのプロセスを生成)
await mcp.connect(new StdioServerTransport())
// すべての POST を Claude に転送する HTTP サーバーを開始
Bun.serve({
port: 8788, // 任意のオープンポートが機能
// localhost のみ:このマシンの外からは何も POST できない
hostname: '127.0.0.1',
async fetch(req) {
const body = await req.text()
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body, // <channel> タグの本文になる
// 各キーはタグ属性になる、例:<channel path="/" method="POST">
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})
30行ほどです。やっていることは3つ。
- サーバー設定:
capabilities.experimentalに'claude/channel': {}を入れる。このキーの存在がチャネルにします。instructionsは Claude のシステムプロンプトに入り、どんなイベントが来るか、返信するかどうか、するならどう扱うかを伝えます。 - stdio 接続:標準的な MCP サーバーと同じ。
- HTTP リスナー:ポート 8788 で受け、POST 本文を
mcp.notification()で流す。contentがイベント本文に、各metaエントリが<channel>タグの属性になります。
リスナーが同じプロセスで動いている理由も説明されています——mcp インスタンスへのアクセスが必要だから。大きなプロジェクトなら別モジュールに分けられます。
なお hostname: '127.0.0.1' のコメントに注目してください。**localhost のみにバインドすることで、このマシンの外からは POST できません。**最小構成でも防御は入っています。
登録して起動する
.mcp.json に追加します。
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}
プロジェクトレベルの .mcp.json(同じディレクトリ)なら相対パス、~/.claude.json のユーザーレベル設定なら絶対パスを使います。どのプロジェクトからでも見つかるようにするためです。
起動は開発フラグ付きで行います。
claude --dangerously-load-development-channels server:webhook
初回は .mcp.json の新しいサーバーについて同意を求められます(「このプロジェクトで見つかった新しい MCP サーバー:webhook」)。**サーバーは自分で起動する必要はありません。**Claude Code が MCP 設定を読んで webhook.ts をサブプロセスとして生成し、HTTP リスナーも自動で立ちます。
起動バナーの下に登録確認の通知が出ます。「組織ポリシーによってブロックされています」と出たら、管理者による有効化(第45回の channelsEnabled)が先です。
動かしてみる
別ターミナルから POST します。
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
Claude Code セッションにはこう届きます。
<channel source="webhook" path="/" method="POST">build failed on main: https://ci.example.com/run/1234</channel>
source はサーバーの設定名から自動で入り、meta に渡した path と method が属性になっているのが分かります。
トラブルシューティングの切り分けが curl の結果で分岐するのが実用的です。
curlは成功するが Claude に何も届かない → セッションで/mcpを実行してサーバーのステータスを見る。「接続に失敗」は依存関係かインポートのエラーが定番。~/.claude/debug/<session-id>.txtのデバッグログに stderr のトレースが出ます。curlが「接続が拒否されました」 → ポートがまだバインドされていないか、前回の実行の古いプロセスが掴んでいる。lsof -i :<port>で確認し、killしてから再開。
サーバーオプション
Server コンストラクタで設定する項目は4つです。instructions と capabilities.tools は標準 MCP、残り2つがチャネル固有の追加です。
| フィールド | 説明 |
|---|---|
capabilities.experimental['claude/channel'] | **必須。常に {}。**存在が通知リスナーを登録する |
capabilities.experimental['claude/channel/permission'] | オプション。常に {}。権限リレーを受け取れることを宣言する |
capabilities.tools | **双方向のみ。**常に {}。標準 MCP のツール機能 |
instructions | **推奨。**Claude のシステムプロンプトに追加される。期待するイベント、<channel> 属性の意味、返信の要否と使うツール、返送すべき属性(chat_id など)を伝える |
一方向チャネルにするなら capabilities.tools を省略します。
通知フォーマット
notifications/claude/channel のパラメータは2つだけ。
| フィールド | 型 | 説明 |
|---|---|---|
content | string | イベント本文。<channel> タグの本文になる |
meta | Record<string, string> | オプション。各エントリがタグの属性になる |
**meta のキー命名に落とし穴があります。**キーは識別子でなければならず、文字・数字・アンダースコアのみ。**ハイフンなどを含むキーはサイレントにドロップされます。**エラーが出ないので、気づかないまま属性が消えます。
発行の例と、届く形はこうです。
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: 'build failed on main: https://ci.example.com/run/1234',
meta: { severity: 'high', run_id: '1234' },
},
})
<channel source="your-channel" severity="high" run_id="1234">
build failed on main: https://ci.example.com/run/1234
</channel>
配信の保証について、重要な注意が3つあります。
- 通知は確認されません。
mcp.notification()のawaitはメッセージがトランスポートに書き込まれた時点で解決し、Claude が処理した時点ではありません。 - セッションがサーバーをチャネルとして読み込んでいない場合や、組織ポリシーがブロックしている場合、イベントはサーバーにエラーを返さずサイレントにドロップされます。
- 配信確認が要るなら、サーバー側でイベント状態を追跡し、Claude が状態を報告するために呼べる返信ツールを公開する——これが公式の推奨する回避策です。
そして処理順序。**イベントはセッションにキューされ、順番に処理されます。**Claude がビジーな間に複数届いた場合は、次のターンでまとめて配信され、Claude はグループとして処理します。
返信ツールを公開する(双方向にする)
チャットブリッジにするなら、Claude が呼べる標準の MCP ツールを公開します。**ツール登録にチャネル固有のものは何もありません。**必要なのは3つ。
- コンストラクタの機能に
tools: {}(Claude Code がツールを検出できるように) - ツールハンドラー(スキーマ定義と送信ロジック)
instructions文字列(いつどう呼ぶかを Claude に伝える)
機能に1行足します。
capabilities: {
experimental: { 'claude/channel': {} },
tools: {}, // ツール検出を有効化
},
ハンドラーを2つ登録します。
// webhook.ts の上部に追加
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 は Claude に渡すべき引数を伝える
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 がツールを呼び出したいときにこれを呼び出す
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
// send() はアウトバウンド:チャットプラットフォームに POST、またはローカル
// テストの場合は下の完全な例に示されている SSE ブロードキャスト。
send(`Reply to ${chat_id}: ${text}`)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})
そして instructions を更新して、インバウンドのタグから chat_id を渡すよう Claude に伝えます。
instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'
instructions が単なる説明文ではなく、返信のルーティングを成立させる要であることが分かります。
インバウンドをゲートする(最重要)
**ゲートなしチャネルはプロンプトインジェクションのベクトルです。**チャットプラットフォームやパブリックなエンドポイントを聴くチャネルには、何かを発行する前に実際の送信者チェックが必要です。
const allowed = new Set(loadAllowlist()) // access.json またはそれに相当するもの
// メッセージハンドラー内、発行する前:
if (!allowed.has(message.from.id)) { // 送信者、ルームではない
return // サイレントにドロップ
}
await mcp.notification({ ... })
間違えやすい点が名指しで警告されています。ゲートするのはチャットやルームの ID ではなく、送信者の ID——例では message.from.id であって message.chat.id ではありません。理由は明快で、グループチャットではこの2つが異なり、ルームでゲートすると、許可されたグループ内の誰もがセッションにメッセージを注入できてしまうからです。
既製プラグインの実装も同じ方式です。Telegram と Discord はペアリングで許可リストを立ち上げ(DM → ペアリングコード → セッションで承認 → ID 追加)、iMessage は起動時にメッセージ DB からユーザー自身のアドレスを検出して自動的に通し、他の送信者はハンドルで追加します。
権限プロンプトをリレーする
Claude が承認の要るツールを呼ぶと、ローカルターミナルにダイアログが開いてセッションが待機します。双方向チャネルは、同じプロンプトを並行して受け取り、別デバイスにリレーできます。
両方がライブのままである点が重要です。ターミナルでも電話でも答えられ、Claude Code は最初に到着した答えを適用してもう一方を閉じます。
リレーの対象は Bash、Write、Edit などのツール使用承認。プロジェクト信頼と MCP サーバー同意ダイアログはリレーされません。
仕組みは4ステップです。
- Claude Code が短いリクエスト ID を生成し、サーバーに通知する
- サーバーがプロンプトと ID をチャットアプリに転送する
- リモートのユーザーが yes / no と ID で返信する
- インバウンドハンドラーが返信を判定に解析し、Claude Code は ID が開いているリクエストと一致する場合のみ適用する
アウトバウンド通知は notifications/claude/channel/permission_request で、params は4つの文字列フィールドです。
| フィールド | 説明 |
|---|---|
request_id | **a〜z から l を除いた5文字の小文字。**電話で入力するとき 1 や I と読み間違えられないため。ローカルダイアログはこの ID を表示しないので、アウトバウンドハンドラーが唯一の知る手段 |
tool_name | Bash、Write など |
description | この呼び出しが何をするかの人間可読な要約。ローカルダイアログが表示するのと同じテキスト |
input_preview | ツール引数を JSON 文字列にして200文字に切り詰めたもの。何を表示するかはサーバーが決める |
l を除く理由の説明が丁寧です。電話で入力する前提があるからこその設計判断で、こういう細部に実運用の想定が現れています。
返送する判定は notifications/claude/channel/permission で、フィールドは2つ——ID を反映する request_id と、'allow' または 'deny' の behavior。どちらの判定も将来の呼び出しには影響しません(「今後も許可」のような永続化はない)。
実装は3コンポーネント。まず機能宣言です。
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // 権限リレーにオプトイン
},
tools: {},
},
次に受信ハンドラー。
import { z } from 'zod'
// setNotificationHandler はメソッドフィールドで z.literal にルーティングするため、
// このスキーマはバリデータとディスパッチキーの両方です
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(), // 5 つの小文字、プロンプトに逐語的に含める
tool_name: z.string(), // 例:'Bash'、'Write'
description: z.string(), // この呼び出しが何をするかの人間が読める要約
input_preview: z.string(), // ツール引数を JSON として、約 200 文字に切り詰め
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
// send() はアウトバウンド:チャットプラットフォームに POST、またはローカル
// テストの場合は下の完全な例に示されている SSE ブロードキャスト。
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
// 命令の ID はステップ 3 でインバウンドハンドラーが解析するもの
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})
最後に、インバウンドハンドラーで判定を横取りします。
// 'y abcde'、'yes abcde'、'n abcde'、'no abcde'と一致
// [a-km-z] は Claude Code が使用する ID アルファベット(小文字、'l'をスキップ)
// /i はオートコレクト大文字を許容;送信する前に取得を小文字にする
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
async function onInbound(message: PlatformMessage) {
if (!allowed.has(message.from.id)) return // 最初に送信者でゲート
const m = PERMISSION_REPLY_RE.exec(message.text)
if (m) {
// m[1] は判定単語、m[2] はリクエスト ID
// チャットの代わりに Claude Code に判定通知を発行
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(), // オートコレクト大文字の場合は正規化
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // 判定として処理、チャットとしても転送しない
}
// 判定フォーマットと一致しない:通常のチャットパスにフォールスルー
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
})
}
正規表現に /i を付け、取得後に小文字化している理由が「電話のオートコレクトが返信を大文字にする」ことへの対処である点も、実運用を踏まえた設計です。
リモート返信が失敗する2パターンも明示されています。どちらの場合もダイアログは開いたままです。
- フォーマットが違う(
approve itや ID なしのyes)→ 正規表現にマッチせず、通常のメッセージとして Claude に流れます。 - フォーマットは正しいが ID が違う → サーバーは判定を発行するが、Claude Code は該当する開いたリクエストを見つけられず、サイレントにドロップします。
動作確認は3ターミナルで
公式は組み立て済みの完全な webhook.ts(返信ツール+送信者ゲート+権限リレー)を掲載しています。この記事では長いため引用せず、公式を参照してください——上で引用した部品を組み合わせたものです。アウトバウンドは GET /events の SSE でストリームし、インバウンドは POST / で受ける構成になっています。
テスト手順は3ターミナル。1つ目は Claude Code セッション。
claude --dangerously-load-development-channels server:webhook
2つ目でアウトバウンドをストリーム。
curl -N localhost:8788/events
3つ目でメッセージを送ります。
curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788
ここでの説明が細かくて良いです。ファイル一覧は読み取り専用なので承認なしに実行され、権限ダイアログが開くのは Claude が reply ツールを呼んで答えを返そうとしたときです。mcp__webhook__reply のプロンプトが /events ストリームに5文字の ID 付きで現れるので、リモートから承認します。
curl -d "yes <id>" -H "X-Sender: dev" localhost:8788
ローカルダイアログが閉じ、reply が実行され、Claude の返信がストリームに届きます。
リサーチプレビュー中のテスト
--dangerously-load-development-channels はエントリごとに許可リストをバイパスします。プラグイン形式(plugin:yourplugin@yourmarketplace)と、素の .mcp.json サーバー(server:webhook)の両方を指定できます。
# 開発中のプラグインをテスト
claude --dangerously-load-development-channels plugin:yourplugin@yourmarketplace
# ベアな .mcp.json サーバーをテスト(プラグインラッパーはまだない)
claude --dangerously-load-development-channels server:webhook
**このフラグを --channels と組み合わせても、バイパスは --channels のエントリには広がりません。そしてフラグがスキップするのは許可リストだけで、channelsEnabled の組織ポリシーは引き続き適用されます。**信頼できないソースのチャネルを動かすために使ってはいけません。
プラグインとしてパッケージ化する
インストール可能・共有可能にするには、プラグインでラップしてマーケットプレイスに公開します。ユーザーは /plugin install で入れ、--channels plugin:<name>@<marketplace> でセッションごとに有効化します。
ただし配布の現実は厳しめです。独自マーケットプレイスに公開したチャネルは承認許可リストに入らないので、実行には --dangerously-load-development-channels が必要なままです。デフォルトの許可リストは claude-plugins-official のチャネルプラグインで、Anthropic が裁量で管理しています。アプリ内送信フォームはコミュニティマーケットプレイスに追加するもので、チャネル許可リストではありません。
現実的な道は2つ。Anthropic のパートナー連絡先がいれば公式リストの調整を相談する、または Team / Enterprise の管理者が組織の allowedChannelPlugins(第45回)にそのプラグインを含める——これはデフォルトの Anthropic 許可リストを置き換えます。
4. まとめ + 次回予告
- チャネルは**
claude/channelを宣言しnotifications/claude/channelを発行する MCP サーバー**。要件は@modelcontextprotocol/sdkと Node.js 互換ランタイムだけ。 - Claude Code がサブプロセスとして生成し stdio で通信する。だからチャット系はポーリング、公開 URL 不要、webhook 系はローカルポートで待つ。
- 最小の一方向チャネルは30行程度。
contentが本文、metaの各キーがタグ属性になる。metaのキーは文字・数字・アンダースコアのみ。ハイフン入りはサイレントにドロップ。 instructionsが要。Claude のシステムプロンプトに入り、返信のルーティングまで規定する。- 通知は確認されない。読み込まれていない/ポリシーでブロックされている場合もエラーなしでサイレントにドロップ。確認が要るなら返信ツールで状態を報告させる。
- イベントはキューされ順番に処理。並行処理が要るなら別セッション。
- 双方向化は標準 MCP ツールを公開するだけ——
tools: {}、ハンドラー2つ、instructionsの更新。 - **ゲートは必須。送信者 ID でゲートし、ルーム ID でゲートしない。**グループチャットでは別物で、ルームでゲートすると同じグループの誰でも注入できる。
- 権限リレーは
claude/channel/permissionを宣言して受信ハンドラーと判定パーサーを足す。ローカルとリモートの両方がライブで、先に来た答えが勝つ。 request_idはlを除く5文字(電話入力を想定)。ローカルダイアログには表示されない。判定はその呼び出し限りで永続化しない。- **権限リレーは送信者認証があるチャネルでのみ宣言する。**返信できる者は誰でもツール使用を承認・拒否できる。
- リサーチプレビュー中はカスタムチャネル=許可リスト外。
--dangerously-load-development-channelsはエントリごとにバイパスし、組織ポリシーはスキップしない。
第45回で「使う側」を扱ってから9回を経て、ようやく「作る側」に辿り着きました。チャネルはこれで一通りです。
次回予告(暫定):チャネルの配布に必要な**プラグインマーケットプレイス(plugin-marketplaces)**を取り上げ、プラグインの公開方法とマーケットプレイスの仕組みを扱う予定です。

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。
それでは、また明日お会いしましょう(^^)

コメント