Channels 레퍼런스 (Channels reference)
기준일: 2026-10-23
공식 기준: Channels reference
개요
channel은 Claude Code 세션에 이벤트를 밀어 넣는 MCP 서버입니다. 이를 통해 Claude가 터미널 밖에서 일어나는 일에 반응할 수 있습니다.
- 단방향 channel: 알림, webhook, 모니터링 이벤트를 Claude에 전달해 처리하게 합니다.
- 양방향 channel: 채팅 브리지처럼 reply tool도 노출해 Claude가 메시지를 돌려보낼 수 있습니다.
- 신뢰할 수 있는 발신자 경로가 있는 channel은 권한 프롬프트 relay에 opt-in해 tool 사용을 원격으로 승인·거부하게 할 수 있습니다.
Channels는 research preview 단계입니다. Team·Enterprise 조직은 명시적으로 활성화해야 합니다.
만들지 않고 기존 channel을 쓰려면 Channels를 보세요. research preview에는 Telegram, Discord, iMessage, fakechat이 포함되어 있습니다.
핵심 개념
| 항목 | 내용 |
|---|---|
| 실행 방식 | Claude Code와 같은 머신에서 subprocess로 실행, stdio로 통신 |
| 필수 의존성 | @modelcontextprotocol/sdk + Node.js 호환 런타임(Bun, Node, Deno) |
| channel 선언 | capabilities.experimental['claude/channel']: {} |
| 이벤트 발행 | notifications/claude/channel (content, meta) |
| 양방향 | capabilities.tools: {} + 표준 MCP tool(예: reply) |
| 권한 relay | capabilities.experimental['claude/channel/permission']: {}, permission_request 수신 → permission verdict 전송 |
| 로컬 테스트 | --dangerously-load-development-channels server:<name> 또는 plugin:<name>@<marketplace> |
상세
동작 방식
channel은 Claude Code와 같은 머신에서 도는 MCP 서버입니다. Claude Code가 subprocess로 띄우고 stdio로 통신합니다. channel 서버는 외부 시스템과 Claude Code 세션 사이의 다리입니다.
- 채팅 플랫폼(Telegram, Discord): 플러그인이 로컬에서 플랫폼 API를 polling해 새 메시지를 받습니다. 누군가 봇에 DM을 보내면 플러그인이 받아 Claude로 전달합니다. 외부에 노출할 URL이 없습니다.
- Webhook(CI, 모니터링): 서버가 로컬 HTTP 포트에서 대기합니다. 외부 시스템이 그 포트로 POST하면 서버가 payload를 Claude로 밀어 넣습니다.
필요한 것
유일한 필수 조건은 @modelcontextprotocol/sdk 패키지와 Node.js 호환 런타임입니다. Bun, Node, Deno 모두 됩니다. research preview의 기본 제공 플러그인은 Bun을 쓰지만 직접 만드는 channel은 그럴 필요가 없습니다.
서버가 해야 할 일은 세 가지입니다.
claude/channelcapability를 선언해 Claude Code가 notification listener를 등록하게 함- 무슨 일이 생기면
notifications/claude/channel이벤트를 발행 - stdio transport로 연결
research preview 기간에는 커스텀 channel이 승인된 allowlist에 없으므로 로컬 테스트에 --dangerously-load-development-channels를 씁니다.
예제: webhook 수신기 만들기
HTTP 요청을 받아 Claude Code 세션으로 전달하는 단일 파일 서버를 만듭니다. 완성하면 CI 파이프라인, 모니터링 알림, curl처럼 HTTP POST를 보낼 수 있는 무엇이든 Claude에 이벤트를 보낼 수 있습니다. 예제는 내장 HTTP 서버와 TypeScript 지원 때문에 Bun을 쓰지만 Node나 Deno도 됩니다.
1. 프로젝트 만들기
권한 relay 예제가 zod를 직접 import하므로 MCP SDK와 함께 설치합니다.
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk zod
2. channel 서버 작성
webhook.ts가 channel 서버 전체입니다. stdio로 Claude Code에 연결하고 8788 포트에서 HTTP POST를 기다리다가, 요청이 오면 본문을 channel 이벤트로 Claude에 보냅니다.
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
// Create the MCP server and declare it as a channel
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
// this key is what makes it a channel — Claude Code registers a listener for it
capabilities: { experimental: { 'claude/channel': {} } },
// Claude Code delivers this to Claude as context when the server connects, so it knows how to handle these events
instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
},
)
// Connect to Claude Code over stdio (Claude Code spawns this process)
await mcp.connect(new StdioServerTransport())
// Start an HTTP server that forwards every POST to Claude
Bun.serve({
port: 8788, // any open port works
// localhost-only: nothing outside this machine can POST
hostname: '127.0.0.1',
async fetch(req) {
const body = await req.text()
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body, // becomes the body of the <channel> tag
// each key becomes a tag attribute, e.g. <channel path="/" method="POST">
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})
파일은 서버 설정 → stdio 연결 → HTTP listener 시작 순서로 동작합니다.
- 서버 설정: capabilities에
claude/channel을 넣어 MCP 서버를 만듭니다. 이것이 Claude Code에 channel임을 알립니다.instructions문자열은 서버 연결 시 Claude에게 컨텍스트로 전달되므로, 어떤 이벤트가 오는지, 답해야 하는지, 답한다면 어떻게 라우팅하는지 적습니다. - stdio 연결: stdin/stdout으로 Claude Code에 연결합니다. 모든 MCP 서버의 표준 방식입니다.
- HTTP listener: 8788 포트에 로컬 웹 서버를 띄웁니다. 모든 POST 본문이
mcp.notification()으로 channel 이벤트가 됩니다.content는 이벤트 본문, 각meta항목은<channel>태그의 속성이 됩니다. listener가mcp인스턴스에 접근해야 하므로 같은 프로세스에서 돌며, 큰 프로젝트라면 모듈로 나눌 수 있습니다.
3. Claude Code에 서버 등록
MCP 설정에 서버를 추가합니다. 같은 디렉터리의 프로젝트 수준 .mcp.json이면 상대 경로를, 사용자 수준 ~/.claude.json이면 어느 프로젝트에서도 찾을 수 있게 절대 경로를 씁니다.
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}
Claude Code는 시작할 때 MCP 설정을 읽고 각 서버를 subprocess로 띄웁니다.
4. 테스트
research preview 기간에는 커스텀 channel이 allowlist에 없으므로 개발 플래그로 시작합니다.
claude --dangerously-load-development-channels server:webhook
- 먼저 로드할 개발 channel 목록을 보여 주는 전체 화면 경고가 뜹니다. I am using this for local development를 선택하면 계속, Exit를 선택하면 종료합니다.
- 이 프로젝트에서 처음 세션을 시작하면
.mcp.json의 새 서버 사용 동의도 묻습니다. "New MCP server found in this project: webhook" 대화상자에서 Use this MCP server를 선택합니다. - 수락하면 Claude Code가
webhook.ts를 subprocess로 띄우고 HTTP listener가 설정한 포트(예제는 8788)에서 자동으로 시작됩니다. 서버를 직접 실행할 필요가 없습니다. - 시작 배너 아래 흐린 안내가 channel 등록을 확인해 줍니다:
Channels (experimental) messages from server:webhook inject directly in this session · restart without --dangerously-load-development-channels to stop - "blocked by org policy"가 보이면 조직 admin이 먼저 channels를 활성화해야 합니다.
다른 터미널에서 webhook을 흉내 내 POST를 보냅니다.
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
payload는 Claude 컨텍스트에 <channel> 태그로 도착합니다.
<channel source="webhook" path="/" method="POST">
build failed on main: https://ci.example.com/run/1234
</channel>
터미널에는 원본 태그 대신 ← webhook: build failed on main: https://ci.example.com/run/1234 한 줄 요약이 표시되고, Claude가 파일을 읽거나 명령을 실행하는 등 메시지에 맞게 반응합니다. 단방향 channel이므로 Claude는 세션 안에서 행동할 뿐 webhook으로 아무것도 돌려보내지 않습니다.
이벤트가 도착하지 않으면 curl 결과에 따라 진단합니다.
curl은 성공했는데 Claude에 아무것도 오지 않음: 세션에서/mcp로 서버 상태를 확인합니다.failed는 보통 서버 파일의 의존성·import 오류입니다. stderr를 보려면claude --debug --dangerously-load-development-channels server:webhook으로 재시작하고~/.claude/debug/<session-id>.txtdebug 로그를 확인합니다.curl이 "connection refused"로 실패: 포트가 아직 바인딩되지 않았거나 이전 실행의 잔여 프로세스가 포트를 잡고 있습니다.lsof -i :<port>로 확인하고 잔여 프로세스를kill한 뒤 세션을 재시작합니다.
fakechat 서버는 이 패턴에 웹 UI, 파일 첨부, 양방향 채팅용 reply tool을 더한 예입니다.
research preview 중 테스트
research preview 기간에는 모든 channel이 승인된 allowlist에 있어야 등록됩니다. 개발 플래그는 확인 프롬프트를 거친 뒤 지정한 항목에 한해 allowlist를 우회합니다.
# Testing a plugin you're developing
claude --dangerously-load-development-channels plugin:yourplugin@yourmarketplace
# Testing a bare .mcp.json server (no plugin wrapper yet)
claude --dangerously-load-development-channels server:webhook
- 확인 프롬프트를 띄울 수 있는 interactive 세션에서 실행해야 합니다.
-pnon-interactive 모드나 Agent SDK로 넘기면 플래그가 무시되고 channel이 등록되지 않습니다. - 우회는 항목 단위입니다.
--channels와 함께 써도--channels항목까지 우회가 확장되지 않습니다. 개발 중인 channel은 allowlist에 없으므로 빌드·테스트 동안 개발 플래그로 계속 실행합니다. - 이 플래그는 allowlist만 건너뜁니다.
channelsEnabled조직 정책은 그대로 적용됩니다. 신뢰할 수 없는 출처의 channel 실행에 쓰지 마세요.
서버 옵션
channel은 Server 생성자에서 다음 옵션을 설정합니다. instructions와 capabilities.tools는 표준 MCP 필드이고, claude/channel·claude/channel/permission이 channel 전용 추가 항목입니다.
| 필드 | 타입 | 설명 |
|---|---|---|
capabilities.experimental['claude/channel'] |
object |
필수. 항상 {}. 존재하면 notification listener가 등록됨 |
capabilities.experimental['claude/channel/permission'] |
object 또는 false |
선택. {}로 설정하면 권한 relay 요청을 받을 수 있다고 선언하며, Claude Code가 tool 승인 프롬프트를 channel로 전달함. opt-out은 키를 생략하거나 false. v2.1.234 이전에는 false도 선언으로 취급했음 |
capabilities.tools |
object |
양방향 전용. 항상 {}. 표준 MCP tool capability |
instructions |
string |
권장. 서버 연결 시 Claude에게 컨텍스트로 전달됨. 어떤 이벤트가 오는지, <channel> 태그 속성의 의미, 답할지 여부, 답한다면 어떤 tool로 어떤 속성(예: chat_id)을 넘길지 적음 |
단방향 channel은 capabilities.tools를 생략합니다. 양방향 설정 예시입니다.
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
const mcp = new Server(
{ name: 'your-channel', version: '0.0.1' },
{
capabilities: {
experimental: { 'claude/channel': {} }, // registers the channel listener
tools: {}, // omit for one-way channels
},
// Claude Code delivers this to Claude as context when the server connects, so it knows how to handle your events
instructions: 'Messages arrive as <channel source="your-channel" ...>. Reply with the reply tool.',
},
)
Notification 형식
서버는 두 개의 params로 notifications/claude/channel을 발행합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
content |
string |
이벤트 본문. <channel> 태그 본문으로 전달됨 |
meta |
Record<string, string> |
선택. 각 항목이 <channel> 태그 속성이 되어 chat ID, 발신자 이름, 알림 심각도 같은 라우팅 컨텍스트를 제공. 키는 식별자(영문자·숫자·밑줄)만 허용. 하이픈 등 다른 문자가 들어간 키는 조용히 버려짐 |
Server 인스턴스의 mcp.notification()으로 이벤트를 보냅니다.
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' },
},
})
source 속성은 서버에 설정된 이름으로 자동 지정됩니다.
<channel source="your-channel" severity="high" run_id="1234">
build failed on main: https://ci.example.com/run/1234
</channel>
전달 동작:
- Claude Code는 notification에 ack를 보내지 않습니다.
mcp.notification()의await는 Claude가 처리했을 때가 아니라 transport에 쓰였을 때 resolve됩니다. - 세션이 서버를 channel로 로드하지 않았거나 조직 정책이 막으면 Claude Code는 이벤트를 조용히 버리고 서버에 에러를 반환하지 않습니다.
- 전달 확인이 필요하면 서버에서 이벤트 상태를 추적하고, Claude가 상태를 보고할 수 있는 reply tool을 노출합니다.
- 이벤트는 세션에 큐잉되어 순서대로 처리됩니다. Claude가 바쁜 동안 여러 개가 오면 다음 턴에 함께 전달되어 묶음으로 처리됩니다. 독립적인 이벤트 스트림을 동시에 처리하려면 세션을 따로 돌립니다.
reply tool 노출
알림 전달기가 아니라 채팅 브리지 같은 양방향 channel이라면, Claude가 메시지를 돌려보낼 수 있는 표준 MCP tool을 노출합니다. tool 등록 자체에 channel 전용 요소는 없습니다. reply tool은 세 부분으로 구성됩니다.
- Claude Code가 tool을 발견하도록
Server생성자 capabilities에tools: {} - tool 스키마를 정의하고 전송 로직을 구현하는 tool handler
- Claude에게 언제·어떻게 tool을 호출할지 알려 주는
instructions문자열
위 webhook 수신기에 추가하는 방법입니다.
1. tool 발견 활성화
capabilities: {
experimental: { 'claude/channel': {} },
tools: {}, // enables tool discovery
},
2. reply tool 등록
import는 파일 맨 위에, 두 handler는 Server 생성자와 mcp.connect() 사이에 둡니다. chat_id와 text로 호출하는 reply tool을 등록합니다.
// Add this import at the top of webhook.ts
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
// Claude queries this at startup to discover what tools your server offers
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
// inputSchema tells Claude what arguments to pass
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 calls this when it wants to invoke a tool
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
// send() is your outbound: POST to your chat platform, or for local
// testing the SSE broadcast shown in the full example below.
send(`Reply to ${chat_id}: ${text}`)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})
3. instructions 수정
Claude가 답을 tool로 라우팅하도록, 들어온 태그의 chat_id를 넘기라고 알립니다.
instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'
공식 페이지에는 양방향을 지원하는 완성 webhook.ts가 있습니다. 나가는 답은 GET /events의 Server-Sent Events(SSE)로 흘러가므로 curl -N localhost:8788/events로 실시간으로 볼 수 있고, 들어오는 채팅은 POST /로 받습니다. 이 구조는 아래 전체 예제에 발신자 검증·권한 relay와 함께 그대로 들어 있습니다(양방향 예제는 각 POST의 meta에 증가하는 chat_id와 path·method를 넣고, 전체 예제는 chat_id·path만 넣습니다). 파일 첨부와 메시지 편집까지 갖춘 더 완전한 예는 fakechat 서버를 보세요.
들어오는 메시지 검증 (Gate inbound messages)
검증하지 않은 channel은 prompt injection 경로입니다. 엔드포인트에 닿을 수 있는 누구나 Claude 앞에 텍스트를 놓을 수 있습니다. 채팅 플랫폼이나 공개 엔드포인트를 듣는 channel은 무엇이든 발행하기 전에 실제 발신자 검사가 필요합니다.
mcp.notification() 호출 전에 발신자를 allowlist와 대조합니다.
const allowed = new Set(loadAllowlist()) // from your access.json or equivalent
// inside your message handler, before emitting:
if (!allowed.has(message.from.id)) { // sender, not room
return // drop silently
}
await mcp.notification({ ... })
- chat·room ID가 아니라 발신자 ID로 검증합니다(예제의
message.from.id,message.chat.id아님). 그룹 채팅에서는 둘이 다르며, room으로 검증하면 allowlist에 있는 그룹의 아무나 세션에 메시지를 주입할 수 있습니다. - Telegram·Discord channel도 같은 방식으로 발신자 allowlist를 검사하며, pairing으로 목록을 처음 채웁니다. 전체 pairing 흐름은 두 구현을 참고하세요.
- iMessage channel은 다르게, 시작 시 Messages 데이터베이스에서 사용자 본인 주소를 감지해 자동 통과시키고 다른 발신자는 handle로 추가합니다.
권한 프롬프트 relay
Claude가 승인이 필요한 tool을 호출하면 로컬 터미널 대화상자가 열리고 세션이 기다립니다. 양방향 channel은 같은 프롬프트를 병렬로 받아 다른 기기로 relay하도록 opt-in할 수 있습니다. 둘 다 살아 있어 터미널이나 휴대폰 어느 쪽에서든 답할 수 있고, Claude Code는 먼저 도착한 답을 적용하고 다른 쪽을 닫습니다.
- relay 대상은
Bash,Write,Edit같은 tool 사용 승인입니다. 프로젝트 trust와 MCP 서버 동의 대화상자는 relay되지 않고 로컬 터미널에만 나옵니다. - Claude Code v2.1.234 이상은 해당 세션에서 channel로 등록한 서버에만 권한 요청을 보내므로, relay도 메시지 전달과 같은 세션 opt-in·조직 통제를 따릅니다.
- relay에는
--channels또는 개발 플래그로 서버를 opt-in하는 것과, 서버의 permission capability 선언이 모두 필요합니다.
relay 동작 방식
권한 프롬프트가 열리면 relay 루프는 네 단계로 진행됩니다.
- Claude Code가 짧은 request ID를 만들어 서버에 알림
- 서버가 프롬프트와 ID를 채팅 앱으로 전달
- 원격 사용자가 yes/no와 그 ID로 답장
- inbound handler가 답장을 verdict로 파싱하고, Claude Code는 ID가 열린 요청과 일치할 때만 적용
이 동안 로컬 터미널 대화상자는 열려 있습니다. 원격 verdict보다 터미널 쪽 답이 먼저 오면 그 답이 적용되고 대기 중인 원격 요청은 버려집니다.
권한 요청 필드
Claude Code가 보내는 notification은 notifications/claude/channel/permission_request입니다. channel notification처럼 transport는 표준 MCP지만 method와 스키마는 Claude Code 확장입니다. params에는 서버가 프롬프트로 만들 네 개의 문자열 필드가 있습니다.
| 필드 | 설명 |
|---|---|
request_id |
l을 뺀 a-z 소문자 5자. 휴대폰에서 입력할 때 1이나 I로 읽히지 않게 하기 위함. 답장에 그대로 돌아오도록 프롬프트에 포함. Claude Code는 자신이 발급한 ID를 담은 verdict만 받음. 로컬 터미널 대화상자에는 표시되지 않으므로 outbound handler가 유일한 확인 경로 |
tool_name |
Claude가 쓰려는 tool 이름(예: Bash, Write) |
description |
이 tool 호출이 하는 일의 사람이 읽을 요약. 명령 자체는 아님. Bash에서는 Claude가 쓴 명령 설명이며, 모델이 설명을 주지 않으면 상수 Run shell command로 명령 정보가 전혀 없음. 공간이 있으면 input_preview도 표시할 것 |
input_preview |
tool 인수를 최상위 필드별로 담은 JSON 형태 표시 텍스트. Bash는 명령, Write는 파일 경로와 내용. 한 줄 메시지만 가능하면 생략 가능. 무엇을 보여 줄지는 서버가 결정 |
Claude Code v2.1.211 이상 클라이언트는 relay 전에 description·input_preview를 정리(sanitize)합니다.
- 방향 재정의 문자, 보이지 않는 문자, 따옴표·꺾쇠 유사 문자를 무력화합니다.
- 연속 공백을 공백 하나로 접습니다.
- 3,500 code point까지는 그대로 relay합니다. 더 긴 값은 앞부분과 끝부분 사이에 개수를 센
⋯ N code points elided ⋯표시를 넣어 보냅니다. 긴 명령의 끝부분도 승인자에게 전달됩니다. input_preview에서는 3,500 제한을 최상위 필드마다 따로 적용하고 JSON 자체의 구조용 따옴표는 유지합니다.- v2.1.211 이전 클라이언트는
description을 그대로 보내고input_preview를 UTF-16 200 단위에서 말줄임표를 붙여 자릅니다.
Claude Code v2.1.234 이상 클라이언트는 순환 구조나 아주 큰 배열처럼 안전하게 직렬화할 수 없는 input_preview 필드 값 대신 (value unserializable)을 보냅니다. 필드 키는 그대로 오고 다른 필드는 바뀌지 않습니다.
v2.1.234 이상은 description·input_preview의 자격 증명도 마스킹합니다. API key나 personal access token처럼 인식 가능한 provider 자격 증명 토큰 자리에 [REDACTED]가 옵니다. 표시할 때 다음을 염두에 둡니다.
input_preview에서는 값뿐 아니라 키 이름도 마스킹합니다. 표시되는 키 이름이 실제 입력의 키 이름과 다를 수 있습니다.- shell 문법, 경로 문자, URL 문자가 들어간 구간은 마스킹하지 않습니다. 승인 대상인 명령·파일 경로·목적지를 마스크가 가릴 수 없습니다.
- 인식 가능한 prefix가 없는 secret이나 private-key 블록처럼 공백을 포함하는 secret은 마스킹하지 않아 서버에 그대로 도착합니다.
마스킹은 필드를 받는 대상을 바꾸지 않습니다. 마스킹되지 않은 내용은 --channels나 개발 플래그로 opt-in한 서버에만 갑니다. 클라이언트 전체를 통제하지 않는 한 두 필드를 신뢰할 수 없는 값으로 다루세요.
서버가 돌려보내는 verdict는 notifications/claude/channel/permission이며 두 필드를 가집니다. 위 ID를 그대로 돌려주는 request_id, 그리고 'allow' 또는 'deny'인 behavior. allow는 tool 호출을 진행시키고 deny는 거부합니다. 어느 쪽도 이후 호출에는 영향을 주지 않습니다.
채팅 브리지에 relay 추가
양방향 channel에 권한 relay를 붙이려면 세 가지가 필요합니다.
- Claude Code가 프롬프트를 전달하도록
Server생성자의experimentalcapabilities에claude/channel/permission: {} notifications/claude/channel/permission_request를 받아 프롬프트를 만들고 플랫폼 API로 보내는 notification handler- inbound 메시지 handler에서
yes <id>·no <id>를 인식해, 텍스트를 Claude로 넘기는 대신notifications/claude/channel/permissionverdict를 발행하는 검사
channel을 통해 답할 수 있는 누구나 세션의 tool 사용을 승인·거부할 수 있으므로, channel이 발신자를 인증할 때만 이 capability를 선언하세요.
1. permission capability 선언
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // opt in to permission relay
},
tools: {},
},
2. 들어오는 요청 처리
Server 생성자와 mcp.connect() 사이에 notification handler를 등록합니다. 권한 대화상자가 열리면 Claude Code가 네 필드로 호출합니다. handler는 플랫폼에 맞게 프롬프트를 만들고 ID로 답하는 방법을 포함합니다.
import { z } from 'zod'
// setNotificationHandler routes by z.literal on the method field,
// so this schema is both the validator and the dispatch key
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(), // five lowercase letters, include verbatim in your prompt
tool_name: z.string(), // e.g. "Bash", "Write"
description: z.string(), // summary of this call. Treat as untrusted.
input_preview: z.string(), // tool args as JSON-shaped text. Treat as untrusted.
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
// send() is your outbound: POST to your chat platform, or for local
// testing the SSE broadcast shown in the full example below.
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n` +
// input_preview carries the actual arguments; render it when you
// have room: for Bash the description alone may be just
// "Run shell command" with zero command detail
`${params.input_preview}\n\n` +
// the ID in the instruction is what your inbound handler parses in Step 3
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})
3. inbound handler에서 verdict 가로채기
inbound handler는 플랫폼에서 메시지를 받는 루프나 callback으로, 발신자를 검증하고 notifications/claude/channel로 채팅을 Claude에 넘기는 곳입니다. 채팅 전달 호출 앞에 verdict 형식 검사를 넣어 permission notification을 대신 발행합니다.
정규식은 Claude Code가 만드는 ID 형식(5글자, l 없음)에 맞춥니다. /i 플래그는 휴대폰 자동 수정이 대문자로 바꾼 답도 허용하며, 돌려보내기 전에 캡처한 ID를 소문자로 바꿉니다.
// matches "y abcde", "yes abcde", "n abcde", "no abcde"
// [a-km-z] is the ID alphabet Claude Code uses (lowercase, skips 'l')
// /i tolerates phone autocorrect; lowercase the capture before sending
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 // gate on sender first
const m = PERMISSION_REPLY_RE.exec(message.text)
if (m) {
// m[1] is the verdict word, m[2] is the request ID
// emit the verdict notification back to Claude Code instead of chat
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(), // normalize in case of autocorrect caps
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // handled as verdict, don't also forward as chat
}
// didn't match verdict format: fall through to the normal chat path
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
})
}
형식이 정확히 맞지 않는 원격 답은 두 가지로 실패하며, 두 경우 모두 로컬 터미널 대화상자는 열린 채로 남습니다.
- 형식이 다름: 정규식이 매치되지 않아
approve it이나 ID 없는yes같은 텍스트가 일반 메시지로 Claude에 넘어갑니다. - 형식은 맞지만 ID가 틀림: 서버가 verdict를 발행하지만 Claude Code가 그 ID의 열린 요청을 찾지 못해 조용히 버립니다.
전체 예제
reply tool, 발신자 검증, 권한 relay를 모두 합친 webhook.ts입니다. 여기서 시작한다면 앞의 프로젝트 설정과 .mcp.json 항목도 필요합니다. curl로 양방향을 테스트할 수 있도록 HTTP listener는 두 경로를 제공합니다.
GET /events: SSE 스트림을 열어 두고 나가는 메시지를data:줄로 보냅니다.curl -N으로 Claude의 답과 권한 프롬프트를 실시간으로 볼 수 있습니다.POST /: inbound 쪽. 앞의 handler에 채팅 전달 분기 전 verdict 형식 검사가 추가되었습니다.
#!/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'
import { z } from 'zod'
// --- Outbound: write to any curl -N listeners on /events --------------------
// A real bridge would POST to your chat platform instead.
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)
}
// Sender allowlist. For the local walkthrough we trust the single X-Sender
// header value "dev"; a real bridge would check the platform's user ID.
const allowed = new Set(['dev'])
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // opt in to permission relay
},
tools: {},
},
instructions:
'Messages arrive as <channel source="webhook" chat_id="...">. ' +
'Reply with the reply tool, passing the chat_id from the tag.',
},
)
// --- reply tool: Claude calls this to send a message back -------------------
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}`)
})
// --- permission relay: Claude Code (not Claude) calls this when a dialog opens
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(),
tool_name: z.string(),
description: z.string(),
input_preview: z.string(),
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n` +
`${params.input_preview}\n\n` +
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})
await mcp.connect(new StdioServerTransport())
// --- HTTP on :8788: GET /events streams outbound, POST routes inbound -------
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
let nextId = 1
Bun.serve({
port: 8788,
hostname: '127.0.0.1',
idleTimeout: 0, // don't close idle SSE streams
async fetch(req) {
const url = new URL(req.url)
// GET /events: SSE stream so curl -N can watch replies and prompts live
if (req.method === 'GET' && url.pathname === '/events') {
const stream = new ReadableStream({
start(ctrl) {
ctrl.enqueue(': connected\n\n') // so curl shows something immediately
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' },
})
}
// everything else is inbound: gate on sender first
const body = await req.text()
const sender = req.headers.get('X-Sender') ?? ''
if (!allowed.has(sender)) return new Response('forbidden', { status: 403 })
// check for verdict format before treating as chat
const m = PERMISSION_REPLY_RE.exec(body)
if (m) {
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(),
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return new Response('verdict recorded')
}
// normal chat: forward to Claude as a channel event
const chat_id = String(nextId++)
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: body, meta: { chat_id, path: url.pathname } },
})
return new Response('ok')
},
})
터미널 세 개로 verdict 경로를 테스트합니다.
개발 플래그로 Claude Code 세션을 시작해
webhook.ts를 띄웁니다.claude --dangerously-load-development-channels server:webhook이 테스트는 권한 대화상자 자체를 확인하므로, 세션이 열리면 상태 표시줄에
⏸ manual mode on이 나올 때까지Shift+Tab을 누릅니다. auto 모드에서는 classifier가reply호출을 대신 결정해 원격에서 답할 대화상자가 열리지 않습니다.나가는 쪽 스트림을 엽니다.
curl -N localhost:8788/eventsClaude가 명령을 실행하게 할 메시지를 보냅니다.
curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788
파일 목록 조회는 읽기 전용이라 승인 없이 실행됩니다. 권한 대화상자는 Claude가 답을 보내려고 reply tool을 호출할 때 열립니다. 로컬 대화상자가 Claude Code 터미널에 열리고, 곧 /events 스트림에 5글자 ID가 포함된 mcp__webhook__reply 프롬프트가 나타납니다. 원격에서 승인합니다.
curl -d "yes <id>" -H "X-Sender: dev" localhost:8788
로컬 대화상자가 닫히고 reply tool이 실행되며 Claude의 답이 스트림에 나타납니다.
이 파일의 channel 전용 요소는 세 가지입니다.
- Capabilities:
claude/channel은 notification listener 등록,claude/channel/permission은 권한 relay opt-in,tools는 Claude가 reply tool을 발견하게 함 - Outbound 경로:
replytool handler는 Claude가 대화 응답으로 호출하고,PermissionRequestSchemanotification handler는 권한 대화상자가 열릴 때 Claude Code가 호출합니다. 둘 다send()로/events에 방송하지만 시스템의 다른 부분이 트리거합니다. - HTTP handler:
GET /events는 SSE 스트림 유지,POST는X-Sender헤더로 검증되는 inbound.yes <id>·no <id>본문은 verdict notification으로 Claude Code에 가고 Claude에는 도달하지 않으며, 나머지는 channel 이벤트로 Claude에 전달됩니다.
플러그인으로 패키징
channel을 설치·공유할 수 있게 하려면 플러그인으로 감싸 마켓플레이스에 게시합니다. 사용자는 /plugin install로 설치하고 세션마다 --channels plugin:<name>@<marketplace>로 활성화합니다.
- 자체 마켓플레이스에 게시한 channel도 승인된 allowlist에 없으므로 실행하려면
--dangerously-load-development-channels가 필요합니다. - 기본 allowlist는
claude-plugins-official의 channel 플러그인입니다. community 마켓플레이스는 channel allowlist에 없습니다. - Anthropic 파트너 담당자와 일하고 있다면 공식 마켓플레이스 등재를 조율하세요. Team·Enterprise 플랜에서는 admin이 대신 조직의
allowedChannelPlugins목록에 플러그인을 넣을 수 있으며, 이 목록은 기본 Anthropic allowlist를 대체합니다.
체크리스트
-
Servercapabilities에experimental['claude/channel']: {}를 선언했다. -
instructions에 이벤트 형식, 태그 속성 의미, 답장 여부와 사용할 tool·속성을 적었다. -
meta키를 영문자·숫자·밑줄만으로 지었다(하이픈 키는 조용히 버려짐). - HTTP listener를
127.0.0.1에 바인딩했다. -
mcp.notification()호출 전에 room이 아닌 발신자 ID로 allowlist를 검사한다. - 권한 relay는 발신자 인증이 있을 때만
claude/channel/permission: {}으로 선언했다. - verdict 정규식이
[a-km-z]{5}를 쓰고, 캡처한 ID를 소문자로 바꿔 보낸다. -
description·input_preview를 신뢰할 수 없는 값으로 다루고, Bash는input_preview도 표시한다. - 로컬 테스트는 interactive 세션에서
--dangerously-load-development-channels로 실행했다.
다음 단계
- Channels: Telegram, Discord, iMessage, fakechat 데모 설치·사용과 Team·Enterprise 조직 활성화
- Working channel implementations: pairing 흐름, reply tool, 파일 첨부가 포함된 전체 서버 코드
- MCP: channel 서버가 구현하는 기반 프로토콜
- Plugins:
/plugin install로 설치할 수 있게 channel 패키징
기준일
이 문서는 2026-10-23에 공식 Channels reference 페이지를 기준으로 작성했습니다. 버전 표기(v2.1.211, v2.1.234)는 공식 원문에 있는 내용입니다.