Claude Code ChannelsでWebhook受信サーバーを自作する
Claude CodeのChannelsはMCPサーバーとして自作できます。HTTPのPOSTをstdio経由でセッションに届ける最小構成から、送信元をゲートする設計までを手順で追います。
この記事で作るもの
Claude Code Channelsは、ターミナルの外で起きた出来事をセッションに直接届ける研究プレビュー機能です。TelegramやDiscordのような既存プラグインを使う以外に、自分でMCPサーバーを書いてチャンネルにすることもできます。この記事では、CIの失敗通知や監視アラートのようなHTTPのPOSTを受け取り、Claudeのコンテキストへそのまま流し込む最小構成のwebhook受信サーバーを作ります。
作るのは一方向のチャンネルです。外部システムがPOSTを送るとClaudeがそれを読んで反応しますが、Claude側から呼び出し元へ返信する経路はまだ持ちません。双方向にする場合は後半で扱います。
前提条件
必要なのは@modelcontextprotocol/sdkパッケージと、Node.js互換のランタイムだけです。Bun・Node・Denoのどれでも動きます。この記事では組み込みのHTTPサーバーとTypeScriptサポートを持つBunを使います。
MCPサーバーをゼロから自作する手順を先に押さえておくと、通常のツール定義とチャンネル固有の宣言との違いが見えやすくなります。チャンネル固有の宣言というのは、この後で書くcapabilities.experimentalの中身を指します。
Claude Codeは、claude.aiアカウントかClaude ConsoleのAPIキーで認証している必要があります。Amazon Bedrock・Google Cloud's Agent Platform・Microsoft Foundry経由の利用ではChannelsは使えません。
研究プレビュー期間中、自作チャンネルは承認済みの許可リストに載っていません。ローカルで試すには--dangerously-load-development-channelsフラグが必要です。このフラグは、研究プレビュー中は--channelsと同じくclaude --helpの一覧に出ません。一覧に無くても指定すれば動きます。効果は項目単位で、--channelsと併用しても--channels側の項目の許可リスト確認までは省かれません。
手順1: プロジェクトを作る
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk zodzodは必須ではありませんが、後半で返信ツールや権限中継を試す際にそのまま使えるよう一緒に入れておきます。
手順2: チャンネルサーバーを書く
webhook.tsという1ファイルがサーバーの全体です。Claude Codeとstdio経由で接続し、ポート8788でHTTPのPOSTを待ち受けます。
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
capabilities: { experimental: { 'claude/channel': {} } },
instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
},
)
await mcp.connect(new StdioServerTransport())
// すべてのPOSTをClaudeへ転送するHTTPサーバー
Bun.serve({
port: 8788,
hostname: '127.0.0.1', // localhostのみ。外部からは届かない
async fetch(req) {
const body = await req.text()
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body,
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})チャンネルとして成立する3条件
宣言する
capabilities.experimental['claude/channel']を{}で渡します。キーがあるだけで通知リスナーが登録されます。stdioでつなぐ
StdioServerTransportでClaude Codeと接続します。プロセスはClaude Codeがspawnします。通知で流す
POSTボディを
mcp.notification()で転送します。contentが<channel>タグの本文、metaの各キーが属性になります。
手順3: MCP設定に登録する
プロジェクト直下の.mcp.jsonに相対パスで登録します(~/.claude.jsonに置く場合は絶対パスにします)。
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}Claude Codeは起動時にこの設定を読み、各サーバーをサブプロセスとして立ち上げます。
手順4: 開発用フラグで起動して確認する
開発用フラグを付けて起動します。
claude --dangerously-load-development-channels server:webhook読み込む開発用チャンネルを列挙する全画面警告が出ます。「I am using this for local development」を選んで進みます(「Exit」で終了)。プロジェクトで初めてこのMCPサーバーを起動するセッションでは、「New MCP server found in this project: webhook」という同意ダイアログも出ます。「Use this MCP server」を選んで進みます。承認後はClaude Codeがwebhook.tsをサブプロセスとして起動し、設定したポートでHTTPリスナーも自動的に立ち上がります。自分でサーバーを起動する必要はありません。
起動バナーの下に「Channels (experimental) messages from server:webhook inject directly in this session」という控えめな表示が出ればチャンネルとして登録できています。別のターミナルからPOSTを送って動作を確認します。
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"Claudeのコンテキストには次の形で届きます。
<channel source="webhook" path="/" method="POST">build failed on main: https://ci.example.com/run/1234</channel>ターミナル上は生のタグではなく「← webhook: build failed on main: ...」という1行の要約で表示され、続けてClaudeがファイルを読んだりコマンドを実行したりし始めます。一方向のチャンネルなので、この時点ではClaudeからWebhook側への返信は発生しません。
届いたイベントの扱われ方
mcp.notification()が返すawaitは、メッセージがトランスポートに書き込まれた時点で解決します。Claude Codeは通知を受領確認しないので、Claudeが読んだかどうかは分かりません。セッションがこのサーバーをチャンネルとして読み込んでいない場合や、組織のポリシーでブロックされている場合は、イベントがエラーなしで捨てられます。curlがokを返しても届いている保証にならない点が、この構成のいちばんの落とし穴です。
Claudeが作業中に届いたイベントはキューに入り、順番に処理されます。複数件が溜まると、次のターンでまとめて渡されます。独立したイベントを並行して処理させたい場合は、セッションを分けます。
イベントが届くのは、セッションを開いている間だけです。-pで非対話実行すると、端末入力を必要とするツールは無効になります。権限プロンプトが出たまま離席すると、応答するまでセッションは止まります。
metaのキーは英数字とアンダースコアだけの識別子にします。ハイフンを含むキーはエラーも出ずに落とされるので、run-idではなくrun_idと書きます。たとえばCIの通知なら、meta: { severity: 'high', run_id: '1234' }を渡すと<channel source="webhook" severity="high" run_id="1234">の形で届きます。
受信メッセージは必ずゲートしてから公開する
このままのコードは学習用の最小構成であり、送信元を一切確認していません。ゲートしていないチャンネルはプロンプトインジェクションの入口そのものです。あなたのエンドポイントに届く相手なら誰でも、Claudeの目の前にテキストを差し込めます。
公開する前に、mcp.notification()を呼ぶ手前で送信元を認証し、通らないリクエストには401を返して捨てます。ここでは、送信側と受信側だけが知る共有シークレットでボディのHMAC-SHA256署名を作り、ヘッダーの値と照合する形にします。署名はボディ全体にかかるため、中身を書き換えられたリクエストも通りません。ただし署名だけでは、盗聴された正規リクエストの再送(リプレイ)は通ってしまいます。防ぐには、送信側がタイムスタンプを署名の対象に含め、受信側で古いものを捨てます。
const secret = process.env.WEBHOOK_SECRET
if (!secret) throw new Error('WEBHOOK_SECRET is required')
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
)
// 署名(16進文字列)を、全桁を見る比較で照合する
async function isAuthentic(body: string, signature: string | null) {
if (!signature) return false
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))
const expected = Array.from(new Uint8Array(mac), b => b.toString(16).padStart(2, '0')).join('')
let diff = expected.length ^ signature.length
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ (signature.charCodeAt(i) || 0)
}
return diff === 0
}
Bun.serve({
port: 8788,
hostname: '127.0.0.1',
async fetch(req) {
const body = await req.text()
if (!(await isAuthentic(body, req.headers.get('x-signature-256')))) {
return new Response('unauthorized', { status: 401 }) // 通知は送らない
}
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: body, meta: { path: new URL(req.url).pathname } },
})
return new Response('ok')
},
})比較を===ではなく全桁のXORで行うのは、===が最初に食い違った桁で比較を打ち切るためです。応答時間の差から一致した桁数を測られ、正しい署名を少しずつ推測される経路になります。署名の作り方は送信側と合わせます。x-signature-256は、この記事で決めたヘッダー名です。GitHubのような既存サービスから受ける場合は、そのサービスが付けるヘッダー名と署名の形式に読み替えます。
シークレットは、チームで共有する.mcp.jsonに直接書きません。claudeを起動するシェルでexport WEBHOOK_SECRET=...しておき、.mcp.jsonのサーバー定義のenvに${WEBHOOK_SECRET}と書いて受け渡します。.mcp.jsonの値は${VAR}の形で環境変数が展開されます。
{
"mcpServers": {
"webhook": {
"command": "bun",
"args": ["./webhook.ts"],
"env": { "WEBHOOK_SECRET": "${WEBHOOK_SECRET}" }
}
}
}送信側は、同じシークレットでボディを署名してヘッダーに載せます。
BODY='build failed on main'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')
curl -X POST localhost:8788 -H "x-signature-256: $SIG" -d "$BODY"署名が合えばok、ヘッダーが無いか値が違えばunauthorized(401)が返り、Claudeには何も届きません。
コードはhostname: '127.0.0.1'で待ち受けるので、このままでは外のサービスから届きません。外から受けるには、待ち受けアドレスを変えるか、トンネルなどを間に挟みます。
TelegramやDiscordの公式チャンネルプラグインは、HTTPではなくチャットが入口なので、署名ではなく送信者の許可リストでゲートしています。判定に使うのはルームのIDではなく送信者本人のIDです。グループチャットではこの2つが一致せず、ルーム側で許可すると許可していない相手まで通ってしまうためです。初回は、利用者がボットにメッセージを送るとペアリングコードが返り、Claude Codeのセッション側でそのコードを承認すると送信者IDが許可リストに登録されます。
一方向から双方向にするには
返信が要るかどうかで、Serverコンストラクタの書き方が変わります。
一方向と双方向の違い
一方向
capabilitiesはexperimental['claude/channel']だけです。toolsを省くと、Claudeは受け取るだけで返信しません。CIの失敗通知や監視アラートの受け口に向きます。
双方向
capabilities.toolsに{}を足し、replyのような標準のMCPツールを登録します。instructionsでどの属性(chat_idなど)を渡し返すかを伝えます。
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'],
},
}],
}))
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}`) // send()は自分で用意する送信処理
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})ハンドラーはServerコンストラクタとmcp.connect()の間に置きます。instructionsは「<channel source="webhook" chat_id="...">で届く。replyツールにタグのchat_idを渡して返信する」といった内容に書き換えます。受信側のmetaにもchat_idを足しておかないと、Claudeが渡し返す値がありません。
配信確認が要る場合は、このreplyツールで状態を返させます。通知そのものには受領確認が無いためです。
権限確認をチャット側へ中継する
双方向のチャンネルは、ツール実行の承認プロンプトを別の端末へ中継することもできます。experimentalにclaude/channel/permission: {}を足して宣言します。中継されるのはBash・Write・Editなどの承認で、プロジェクトの信頼ダイアログやMCPサーバーの同意ダイアログは手元のターミナルにしか出ません。
v2.1.234以降のClaude Codeは、チャンネルとして登録したサーバーにしか権限要求を送りません。--channelsか開発用フラグでの有効化が前提になります。返信できる相手は誰でも承認・拒否できてしまうため、送信者を認証するチャンネルだけがこの宣言をする設計です。
承認プロンプトは端末とチャット側の両方に出たままで、先に届いた答えをClaude Codeが採用します。チャット側の判定はnotifications/claude/channel/permissionで返し、request_idとbehavior('allow'か'deny')を渡します。request_idは、lを除く小文字a〜zの5文字です。
よくあるつまずき
curlの結果から原因を絞る
- 1
curlが成功したのにClaudeに届かない
セッション内で
/mcpを実行してサーバーのステータスを見ます。failedなら、サーバーファイル内の依存関係かimportのエラーが典型です。claude --debug --dangerously-load-development-channels server:webhookで再起動すると、stderrのトレースが~/.claude/debug/<session-id>.txtに残ります。 - 2
curlがconnection refusedで失敗する
ポートがまだbindされていないか、前回のプロセスが掴んだままです。
lsof -i :<port>で確かめ、古いプロセスをkillしてからセッションを立て直します。 - 3
「blocked by org policy」と出る
組織管理者がChannelsを有効にしていません。claude.aiのTeam・Enterpriseでは、Ownerロールが「Admin settings → Claude Code → Channels」で有効にするまでブロックされます。有効化は管理設定の
channelsEnabled: trueでも行えます。開発用フラグが飛ばすのは許可リストの確認だけで、channelsEnabledの組織ポリシーは止めたままです。信頼できない送信元のチャンネルを動かす用途には使いません。
claude.ai認証のPro・Maxで組織に属していない場合は、組織側の確認がなく、セッションごとの指定だけで使えます。Consoleは、APIキー認証なら既定で許可されます。管理設定を配布している組織では、channelsEnabledを立てるまでブロックされる扱いです。MCPサーバーが接続できないときの切り分け方は、他のMCPサーバーとも共通します。
まとめ
チャットプラグインは送信者のID、Webhookは署名と、入口の種類によって認証の手段が変わります。返信や権限の中継が必要になった段階で、一方向のサーバーにtoolsの宣言とハンドラーを足して双方向へ進める順序で足ります。
よくある質問
自作したチャンネルは他の人にも配布できますか
プラグインとして包んでマーケットプレイスに公開すれば、/plugin installで導入できます。ただし研究プレビュー中は、自分のマーケットプレイスのチャンネルも承認済み許可リストには載らず、利用者は--dangerously-load-development-channelsが必要です。Team・Enterpriseでは、管理者がallowedChannelPluginsに自社のマーケットプレイスとプラグイン名を登録すると、開発フラグなしで使えます。