메인 콘텐츠로 건너뛰기
채널은 연구 미리보기에 있으며 Claude Code v2.1.80 이상이 필요합니다. claude.ai 로그인이 필요합니다. 콘솔 및 API 키 인증은 지원되지 않습니다. 팀 및 엔터프라이즈 조직은 명시적으로 활성화해야 합니다.
채널은 Claude Code 세션으로 이벤트를 푸시하는 MCP 서버이므로 Claude는 터미널 외부에서 발생하는 일에 반응할 수 있습니다. 단방향 또는 양방향 채널을 구축할 수 있습니다. 단방향 채널은 Claude가 작동할 수 있도록 알림, 웹훅 또는 모니터링 이벤트를 전달합니다. 채팅 브리지와 같은 양방향 채널은 Claude가 메시지를 다시 보낼 수 있도록 회신 도구를 노출합니다. 신뢰할 수 있는 발신자 경로가 있는 채널은 권한 프롬프트를 릴레이하도록 선택할 수 있으므로 원격으로 도구 사용을 승인하거나 거부할 수 있습니다. 이 페이지에서 다루는 내용: 기존 채널을 사용하는 대신 구축하려면 채널을 참조하세요. Telegram, Discord, iMessage 및 fakechat은 연구 미리보기에 포함되어 있습니다.

개요

채널은 Claude Code와 동일한 머신에서 실행되는 MCP 서버입니다. Claude Code는 이를 서브프로세스로 생성하고 stdio를 통해 통신합니다. 채널 서버는 외부 시스템과 Claude Code 세션 간의 브리지입니다:
  • 채팅 플랫폼 (Telegram, Discord): 플러그인이 로컬에서 실행되고 플랫폼의 API를 폴링하여 새 메시지를 확인합니다. 누군가 봇에 DM을 보내면 플러그인이 메시지를 수신하고 Claude로 전달합니다. 노출할 URL이 없습니다.
  • 웹훅 (CI, 모니터링): 서버가 로컬 HTTP 포트에서 수신합니다. 외부 시스템이 해당 포트에 POST하고 서버가 페이로드를 Claude로 푸시합니다.
외부 시스템이 로컬 채널 서버에 연결되고 stdio를 통해 Claude Code와 통신하는 아키텍처 다이어그램

필요한 것

유일한 하드 요구 사항은 @modelcontextprotocol/sdk 패키지와 Node.js 호환 런타임입니다. Bun, Node, Deno 모두 작동합니다. 연구 미리보기의 사전 구축된 플러그인은 Bun을 사용하지만 채널이 반드시 그럴 필요는 없습니다. 서버는 다음을 수행해야 합니다:
  1. claude/channel 기능을 선언하여 Claude Code가 알림 리스너를 등록하도록 함
  2. 무언가 발생할 때 notifications/claude/channel 이벤트를 내보냄
  3. stdio 전송을 통해 연결 (Claude Code가 서버를 서브프로세스로 생성)
서버 옵션알림 형식 섹션에서 각각을 자세히 다룹니다. 전체 연습은 예: 웹훅 수신기 구축을 참조하세요. 연구 미리보기 중에 사용자 정의 채널은 승인된 허용 목록에 없습니다. --dangerously-load-development-channels를 사용하여 로컬에서 테스트합니다. 자세한 내용은 연구 미리보기 중 테스트를 참조하세요.

예: 웹훅 수신기 구축

이 연습은 HTTP 요청을 수신하고 Claude Code 세션으로 전달하는 단일 파일 서버를 구축합니다. 마지막에는 CI 파이프라인, 모니터링 알림 또는 curl 명령과 같이 HTTP POST를 보낼 수 있는 모든 것이 Claude로 이벤트를 푸시할 수 있습니다. 이 예제는 기본 제공 HTTP 서버 및 TypeScript 지원을 위해 Bun을 런타임으로 사용합니다. 대신 Node 또는 Deno를 사용할 수 있습니다. 유일한 요구 사항은 MCP SDK입니다.
1

프로젝트 생성

새 디렉토리를 생성하고 MCP SDK를 설치합니다:
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
2

채널 서버 작성

webhook.ts라는 파일을 생성합니다. 이것이 전체 채널 서버입니다: stdio를 통해 Claude Code에 연결되고 포트 8788에서 HTTP POST를 수신합니다. 요청이 도착하면 본문을 채널 이벤트로 Claude로 푸시합니다.
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')
  },
})
파일은 순서대로 세 가지를 수행합니다:
  • 서버 구성: 기능에 claude/channel이 있는 MCP 서버를 생성합니다. 이것이 Claude Code에 이것이 채널임을 알려줍니다. instructions 문자열은 Claude의 시스템 프롬프트로 이동합니다: Claude에 예상할 이벤트, 회신 여부, 회신해야 하는 경우 사용할 도구 및 전달할 속성(예: chat_id)을 알려줍니다.
  • Stdio 연결: stdin/stdout을 통해 Claude Code에 연결합니다. 이는 모든 MCP 서버에 표준입니다: Claude Code가 이를 서브프로세스로 생성합니다.
  • HTTP 리스너: 포트 8788에서 로컬 웹 서버를 시작합니다. 모든 POST 본문은 mcp.notification()을 통해 채널 이벤트로 Claude로 전달됩니다. content는 이벤트 본문이 되고 각 meta 항목은 <channel> 태그의 속성이 됩니다. 리스너는 mcp 인스턴스에 액세스해야 하므로 동일한 프로세스에서 실행됩니다. 더 큰 프로젝트의 경우 별도의 모듈로 분할할 수 있습니다.
3

Claude Code에 서버 등록

Claude Code가 시작하는 방법을 알 수 있도록 MCP 구성에 서버를 추가합니다. 동일한 디렉토리의 프로젝트 수준 .mcp.json의 경우 상대 경로를 사용합니다. ~/.claude.json의 사용자 수준 구성의 경우 모든 프로젝트에서 서버를 찾을 수 있도록 전체 절대 경로를 사용합니다:
.mcp.json
{
  "mcpServers": {
    "webhook": { "command": "bun", "args": ["./webhook.ts"] }
  }
}
Claude Code는 시작 시 MCP 구성을 읽고 각 서버를 서브프로세스로 생성합니다.
4

테스트

연구 미리보기 중에 사용자 정의 채널은 허용 목록에 없으므로 개발 플래그로 Claude Code를 시작합니다:
claude --dangerously-load-development-channels server:webhook
Claude Code가 시작되면 MCP 구성을 읽고 webhook.ts를 서브프로세스로 생성하며 구성한 포트(이 예제에서는 8788)에서 HTTP 리스너가 자동으로 시작됩니다. 서버를 직접 실행할 필요가 없습니다.“조직 정책에 의해 차단됨”이 표시되면 팀 또는 엔터프라이즈 관리자가 먼저 채널을 활성화해야 합니다.별도의 터미널에서 HTTP POST를 메시지와 함께 서버로 보내 웹훅을 시뮬레이션합니다. 이 예제는 CI 실패 알림을 포트 8788로 보냅니다 (또는 구성한 포트):
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
페이로드는 Claude Code 세션에 <channel> 태그로 도착합니다:
<channel source="webhook" path="/" method="POST">build failed on main: https://ci.example.com/run/1234</channel>
Claude Code 터미널에서 Claude가 메시지를 수신하고 응답을 시작하는 것을 볼 수 있습니다: 파일 읽기, 명령 실행 또는 메시지가 요구하는 모든 작업. 이것은 단방향 채널이므로 Claude는 세션에서 작동하지만 웹훅을 통해 아무것도 다시 보내지 않습니다. 회신을 추가하려면 회신 도구 노출을 참조하세요.이벤트가 도착하지 않으면 진단은 curl이 반환한 것에 따라 달라집니다:
  • curl은 성공하지만 Claude에 도달하지 않음: 세션에서 /mcp를 실행하여 서버의 상태를 확인합니다. “연결 실패”는 일반적으로 서버 파일의 종속성 또는 가져오기 오류를 의미합니다. ~/.claude/debug/<session-id>.txt의 디버그 로그에서 stderr 추적을 확인합니다.
  • curl이 “연결 거부”로 실패함: 포트가 아직 바인딩되지 않았거나 이전 실행의 오래된 프로세스가 포트를 보유하고 있습니다. lsof -i :<port>는 수신 중인 것을 표시합니다. 세션을 다시 시작하기 전에 오래된 프로세스를 kill합니다.
fakechat 서버는 웹 UI, 파일 첨부 및 양방향 채팅을 위한 회신 도구로 이 패턴을 확장합니다.

연구 미리보기 중 테스트

연구 미리보기 중에 모든 채널은 등록하기 위해 승인된 허용 목록에 있어야 합니다. 개발 플래그는 확인 프롬프트 후 특정 항목에 대한 허용 목록을 우회합니다. 이 예제는 두 항목 유형을 모두 보여줍니다:
# 개발 중인 플러그인 테스트
claude --dangerously-load-development-channels plugin:yourplugin@yourmarketplace

# 베어 .mcp.json 서버 테스트 (아직 플러그인 래퍼 없음)
claude --dangerously-load-development-channels server:webhook
우회는 항목별입니다. 이 플래그를 --channels와 결합하면 우회가 --channels 항목으로 확장되지 않습니다. 연구 미리보기 중에 승인된 허용 목록은 Anthropic에서 큐레이션되므로 채널은 구축 및 테스트하는 동안 개발 플래그에 남아 있습니다.
이 플래그는 허용 목록만 건너뜁니다. channelsEnabled 조직 정책은 여전히 적용됩니다. 신뢰할 수 없는 소스의 채널을 실행하는 데 사용하지 마세요.

서버 옵션

채널은 Server 생성자에서 이러한 옵션을 설정합니다. instructionscapabilities.tools 필드는 표준 MCP입니다. capabilities.experimental['claude/channel']capabilities.experimental['claude/channel/permission']은 채널 특정 추가 사항입니다:
필드유형설명
capabilities.experimental['claude/channel']object필수. 항상 {}. 존재는 알림 리스너를 등록합니다.
capabilities.experimental['claude/channel/permission']object선택 사항. 항상 {}. 이 채널이 권한 릴레이 요청을 수신할 수 있음을 선언합니다. 선언되면 Claude Code는 도구 승인 프롬프트를 채널로 전달하므로 원격으로 승인하거나 거부할 수 있습니다. 권한 프롬프트 릴레이를 참조하세요.
capabilities.toolsobject양방향만. 항상 {}. 표준 MCP 도구 기능. 회신 도구 노출을 참조하세요.
instructionsstring권장. Claude의 시스템 프롬프트에 추가됩니다. Claude에 예상할 이벤트, <channel> 태그 속성의 의미, 회신 여부, 회신해야 하는 경우 사용할 도구 및 전달할 속성(예: chat_id)을 알려줍니다.
단방향 채널을 생성하려면 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': {} },  // 채널 리스너를 등록합니다
      tools: {},  // 단방향 채널의 경우 생략합니다
    },
    // Claude의 시스템 프롬프트에 추가되므로 이벤트를 처리하는 방법을 알 수 있습니다
    instructions: 'Messages arrive as <channel source="your-channel" ...>. Reply with the reply tool.',
  },
)
이벤트를 푸시하려면 메서드 notifications/claude/channel으로 mcp.notification()을 호출합니다. 매개변수는 다음 섹션에 있습니다.

알림 형식

서버는 두 개의 매개변수로 notifications/claude/channel을 내보냅니다:
필드유형설명
contentstring이벤트 본문. <channel> 태그의 본문으로 전달됩니다.
metaRecord<string, string>선택 사항. 각 항목은 채팅 ID, 발신자 이름 또는 알림 심각도와 같은 라우팅 컨텍스트를 위해 <channel> 태그의 속성이 됩니다. 키는 식별자여야 합니다: 문자, 숫자 및 밑줄만. 하이픈 또는 다른 문자를 포함하는 키는 자동으로 삭제됩니다.
서버는 Server 인스턴스에서 mcp.notification()을 호출하여 이벤트를 푸시합니다. 이 예제는 두 개의 메타 키가 있는 CI 실패 알림을 푸시합니다:
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> 태그로 래핑된 Claude의 컨텍스트에 도착합니다. source 속성은 서버의 구성된 이름에서 자동으로 설정됩니다:
<channel source="your-channel" severity="high" run_id="1234">
build failed on main: https://ci.example.com/run/1234
</channel>

회신 도구 노출

채널이 양방향인 경우(알림 포워더가 아닌 채팅 브리지), Claude가 메시지를 다시 보낼 수 있도록 표준 MCP 도구를 노출합니다. 도구 등록에 대해 채널 특정 사항은 없습니다. 회신 도구에는 세 가지 구성 요소가 있습니다:
  1. Server 생성자 기능의 tools: {} 항목이므로 Claude Code가 도구를 발견합니다
  2. 도구의 스키마를 정의하고 전송 로직을 구현하는 도구 핸들러
  3. Claude에 도구를 호출할 시기와 방법을 알려주는 Server 생성자의 instructions 문자열
위의 웹훅 수신기에 이를 추가하려면:
1

도구 발견 활성화

webhook.tsServer 생성자에서 Claude Code가 서버가 도구를 제공함을 알 수 있도록 기능에 tools: {}를 추가합니다:
capabilities: {
  experimental: { 'claude/channel': {} },
  tools: {},  // 도구 발견을 활성화합니다
},
2

회신 도구 등록

다음을 webhook.ts에 추가합니다. import는 다른 가져오기와 함께 파일 맨 위로 이동합니다. 두 핸들러는 Server 생성자와 mcp.connect() 사이에 있습니다. 이것은 Claude가 chat_idtext로 호출할 수 있는 reply 도구를 등록합니다:
// 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}`)
})
3

지침 업데이트

Server 생성자의 instructions 문자열을 업데이트하여 Claude가 회신을 도구를 통해 다시 라우팅하는 방법을 알 수 있도록 합니다. 이 예제는 Claude에 인바운드 태그에서 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입니다. 아웃바운드 회신은 Server-Sent Events (SSE)를 사용하여 GET /events를 통해 스트리밍되므로 curl -N localhost:8788/events는 실시간으로 볼 수 있습니다. 인바운드 채팅은 POST /에 도착합니다:
"회신
#!/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'

// --- 아웃바운드: /events의 모든 curl -N 리스너에 쓰기 ---
// 실제 브리지는 대신 채팅 플랫폼에 POST합니다.
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,  // 유휴 SSE 스트림을 닫지 마세요
  async fetch(req) {
    const url = new URL(req.url)

    // GET /events: curl -N가 Claude의 회신을 실시간으로 볼 수 있도록 SSE 스트림
    if (req.method === 'GET' && url.pathname === '/events') {
      const stream = new ReadableStream({
        start(ctrl) {
          ctrl.enqueue(': connected\n\n')  // curl이 즉시 무언가를 표시하도록
          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' },
      })
    }

    // POST: 채널 이벤트로 Claude로 전달합니다
    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')
  },
})
fakechat 서버는 파일 첨부 및 메시지 편집이 있는 더 완전한 예제를 보여줍니다.

인바운드 메시지 게이팅

게이트되지 않은 채널은 프롬프트 주입 벡터입니다. 엔드포인트에 도달할 수 있는 모든 사람이 Claude 앞에 텍스트를 넣을 수 있습니다. 채팅 플랫폼 또는 공개 엔드포인트를 수신하는 채널은 무언가를 내보내기 전에 실제 발신자 확인이 필요합니다. mcp.notification()을 호출하기 전에 발신자를 허용 목록과 비교하여 확인합니다. 이 예제는 집합에 없는 발신자의 메시지를 삭제합니다:
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가 아닙니다. 그룹 채팅에서 이들은 다르며 방에 게이트하면 허용 목록에 있는 그룹의 모든 사람이 세션에 메시지를 주입할 수 있습니다. TelegramDiscord 채널은 동일한 방식으로 발신자 허용 목록에 게이트합니다. 페어링으로 목록을 부트스트랩합니다: 사용자가 봇에 DM을 보내면 봇이 페어링 코드로 회신하고 사용자가 Claude Code 세션에서 승인하며 플랫폼 ID가 추가됩니다. 전체 페어링 흐름은 구현 중 하나를 참조하세요. iMessage 채널은 다른 접근 방식을 취합니다: 시작 시 메시지 데이터베이스에서 사용자의 자신의 주소를 감지하고 자동으로 통과시키며 다른 발신자는 핸들로 추가됩니다.

권한 프롬프트 릴레이

권한 릴레이는 Claude Code v2.1.81 이상이 필요합니다. 이전 버전은 claude/channel/permission 기능을 무시합니다.
Claude가 승인이 필요한 도구를 호출할 때 로컬 터미널 대화가 열리고 세션이 대기합니다. 양방향 채널은 동일한 프롬프트를 병렬로 수신하고 다른 장치의 사용자에게 릴레이하도록 선택할 수 있습니다. 둘 다 활성 상태로 유지됩니다: 터미널 또는 휴대폰에서 답변할 수 있으며 Claude Code는 먼저 도착하는 답변을 적용하고 다른 답변을 닫습니다. 릴레이는 Bash, WriteEdit과 같은 도구 사용 승인을 다룹니다. 프로젝트 신뢰 및 MCP 서버 동의 대화는 릴레이되지 않습니다. 이들은 로컬 터미널에만 나타납니다.

릴레이 작동 방식

권한 프롬프트가 열리면 릴레이 루프에는 네 가지 단계가 있습니다:
  1. Claude Code는 짧은 요청 ID를 생성하고 서버에 알립니다
  2. 서버는 프롬프트 및 ID를 채팅 앱으로 전달합니다
  3. 원격 사용자가 예 또는 아니오로 해당 ID로 회신합니다
  4. 인바운드 핸들러는 회신을 판정으로 구문 분석하고 Claude Code는 ID가 열린 요청과 일치하는 경우에만 적용합니다
로컬 터미널 대화는 이 모든 과정을 통해 열려 있습니다. 터미널의 누군가가 원격 판정이 도착하기 전에 답변하면 해당 답변이 대신 적용되고 보류 중인 원격 요청이 삭제됩니다. 시퀀스 다이어그램: Claude Code가 permission_request 알림을 채널 서버로 보내고, 서버가 프롬프트를 채팅 앱으로 포맷하고 보내며, 인간이 판정으로 회신하고, 서버가 해당 회신을 Claude Code로 다시 권한 알림으로 구문 분석합니다

권한 요청 필드

Claude Code의 아웃바운드 알림은 notifications/claude/channel/permission_request입니다. 채널 알림과 같이 전송은 표준 MCP이지만 메서드 및 스키마는 Claude Code 확장입니다. params 객체에는 서버가 아웃바운드 프롬프트로 포맷하는 네 개의 문자열 필드가 있습니다:
필드설명
request_ida-z에서 그려진 5개의 소문자이며 l을 제외하므로 휴대폰에 입력할 때 1 또는 I로 읽히지 않습니다. 아웃바운드 프롬프트에 포함하여 회신에서 에코할 수 있도록 합니다. Claude Code는 발급한 ID를 가진 판정만 수락합니다. 로컬 터미널 대화는 이 ID를 표시하지 않으므로 아웃바운드 핸들러가 이를 학습하는 유일한 방법입니다.
tool_nameClaude가 사용하려는 도구의 이름(예: Bash 또는 Write).
description이 특정 도구 호출이 수행하는 작업의 인간이 읽을 수 있는 요약이며 로컬 터미널 대화가 표시하는 동일한 텍스트입니다. Bash 호출의 경우 이는 Claude의 명령 설명이거나 주어진 것이 없으면 명령 자체입니다.
input_preview도구의 인수를 JSON 문자열로 200자로 잘린 것입니다. Bash의 경우 명령입니다. Write의 경우 파일 경로 및 콘텐츠의 접두사입니다. 한 줄 메시지만 공간이 있는 경우 프롬프트에서 생략합니다. 서버가 표시할 내용을 결정합니다.
서버가 다시 보내는 판정은 notifications/claude/channel/permission이며 두 필드가 있습니다: 위의 ID를 에코하는 request_id'allow' 또는 'deny'로 설정된 behavior. Allow는 도구 호출을 진행하도록 합니다. Deny는 이를 거부하며 로컬 대화에서 아니오로 답변하는 것과 동일합니다. 어느 판정도 향후 호출에 영향을 주지 않습니다.

채팅 브리지에 릴레이 추가

양방향 채널에 권한 릴레이를 추가하려면 세 가지 구성 요소가 필요합니다:
  1. Server 생성자의 experimental 기능 아래 claude/channel/permission: {} 항목이므로 Claude Code가 프롬프트를 전달하는 방법을 알 수 있습니다
  2. notifications/claude/channel/permission_request에 대한 알림 핸들러가 프롬프트를 포맷하고 플랫폼 API를 통해 전송합니다
  3. 인바운드 메시지 핸들러의 확인이 yes <id> 또는 no <id>를 인식하고 텍스트를 Claude로 전달하는 대신 notifications/claude/channel/permission 판정을 내보냅니다
채널이 발신자를 인증하는 경우에만 기능을 선언합니다. 채널을 통해 회신할 수 있는 모든 사람이 세션에서 도구 사용을 승인하거나 거부할 수 있기 때문입니다. 회신 도구 노출에서 조립된 양방향 채팅 브리지와 같은 것에 이를 추가하려면:
1

권한 기능 선언

Server 생성자에서 experimental 아래 claude/channel 옆에 claude/channel/permission: {}를 추가합니다:
capabilities: {
  experimental: {
    'claude/channel': {},
    'claude/channel/permission': {},  // 권한 릴레이에 옵트인합니다
  },
  tools: {},
},
2

들어오는 요청 처리

Server 생성자와 mcp.connect() 사이에 알림 핸들러를 등록합니다. Claude Code는 권한 대화가 열릴 때 4개의 요청 필드로 호출합니다. 핸들러는 플랫폼에 대한 프롬프트를 포맷하고 ID로 회신하기 위한 지침을 포함합니다:
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}"`,
  )
})
3

인바운드 핸들러에서 판정 가로채기

인바운드 핸들러는 플랫폼에서 메시지를 수신하는 루프 또는 콜백입니다: 발신자에 게이트하고 notifications/claude/channel을 내보내 채팅을 Claude로 전달하는 동일한 위치입니다. 채팅 전달 호출 전에 판정 형식을 인식하고 대신 권한 알림을 내보내는 확인을 추가합니다.정규식은 Claude Code가 생성하는 ID 형식과 일치합니다: 5개 문자, l 없음. /i 플래그는 휴대폰 자동 수정이 회신을 대문자로 만드는 것을 허용합니다. 다시 보내기 전에 캡처된 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) } },
  })
}
Claude Code는 로컬 터미널 대화도 열어 두므로 어느 쪽이든 답변할 수 있으며 먼저 도착하는 답변이 적용됩니다. 예상된 형식과 정확히 일치하지 않는 원격 회신은 두 가지 방식 중 하나로 실패하며 두 경우 모두 대화는 열려 있습니다:
  • 다른 형식: 인바운드 핸들러의 정규식이 일치하지 않으므로 approve it 또는 ID 없는 yes와 같은 텍스트는 Claude로 일반 메시지로 넘어갑니다.
  • 올바른 형식, 잘못된 ID: 서버가 판정을 내보내지만 Claude Code는 해당 ID를 가진 열린 요청을 찾지 못하고 자동으로 삭제합니다.

전체 예제

아래의 조립된 webhook.ts는 이 페이지의 세 가지 확장을 모두 결합합니다: 회신 도구, 발신자 게이팅 및 권한 릴레이. 여기서 시작하는 경우 초기 연습에서 프로젝트 설정 및 .mcp.json 항목도 필요합니다. curl에서 양쪽 방향을 테스트 가능하게 하려면 HTTP 리스너는 두 경로를 제공합니다:
  • GET /events: SSE 스트림을 열어 두고 각 아웃바운드 메시지를 data: 줄로 푸시하므로 curl -N은 Claude의 회신 및 권한 프롬프트가 실시간으로 도착하는 것을 볼 수 있습니다.
  • POST /: 인바운드 측, 이전과 동일한 핸들러이지만 이제 채팅 전달 분기 전에 판정 형식 확인이 삽입되었습니다.
"권한
#!/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'

// --- 아웃바운드: /events의 모든 curl -N 리스너에 쓰기 ---
// 실제 브리지는 대신 채팅 플랫폼에 POST합니다.
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)
}

// 발신자 허용 목록. 로컬 연습의 경우 단일 X-Sender를 신뢰합니다
// 헤더 값 "dev"; 실제 브리지는 플랫폼의 사용자 ID를 확인합니다.
const allowed = new Set(['dev'])

const mcp = new Server(
  { name: 'webhook', version: '0.0.1' },
  {
    capabilities: {
      experimental: {
        'claude/channel': {},
        'claude/channel/permission': {},  // 권한 릴레이에 옵트인합니다
      },
      tools: {},
    },
    instructions:
      'Messages arrive as <channel source="webhook" chat_id="...">. ' +
      'Reply with the reply tool, passing the chat_id from the tag.',
  },
)

// --- reply 도구: 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}`)
    return { content: [{ type: 'text', text: 'sent' }] }
  }
  throw new Error(`unknown tool: ${req.params.name}`)
})

// --- 권한 릴레이: Claude Code (Claude가 아님)가 대화가 열릴 때 이를 호출합니다
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\n` +
    `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
  )
})

await mcp.connect(new StdioServerTransport())

// --- HTTP on :8788: GET /events는 아웃바운드를 스트리밍하고, POST는 인바운드를 라우팅합니다 ---
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,  // 유휴 SSE 스트림을 닫지 마세요
  async fetch(req) {
    const url = new URL(req.url)

    // GET /events: curl -N이 회신 및 프롬프트를 실시간으로 볼 수 있도록 SSE 스트림
    if (req.method === 'GET' && url.pathname === '/events') {
      const stream = new ReadableStream({
        start(ctrl) {
          ctrl.enqueue(': connected\n\n')  // curl이 즉시 무언가를 표시하도록
          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 sender = req.headers.get('X-Sender') ?? ''
    if (!allowed.has(sender)) return new Response('forbidden', { status: 403 })

    // 채팅으로 취급하기 전에 판정 형식을 확인합니다
    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')
    }

    // 일반 채팅: 채널 이벤트로 Claude로 전달합니다
    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')
  },
})
3개의 터미널에서 판정 경로를 테스트합니다. 첫 번째는 Claude Code 세션이며 개발 플래그로 시작되어 webhook.ts를 생성합니다:
claude --dangerously-load-development-channels server:webhook
두 번째에서 아웃바운드 측을 스트리밍하여 Claude의 회신 및 권한 프롬프트가 실시간으로 도착하는 것을 볼 수 있습니다:
curl -N localhost:8788/events
세 번째에서 Claude가 명령을 실행하려고 하는 메시지를 보냅니다:
curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788
로컬 권한 대화가 Claude Code 터미널에서 열립니다. 잠시 후 프롬프트가 /events 스트림에 나타나며 5자 ID를 포함합니다. 원격 측에서 승인합니다:
curl -d "yes <id>" -H "X-Sender: dev" localhost:8788
로컬 대화가 닫히고 도구가 실행됩니다. Claude의 회신은 reply 도구를 통해 돌아오고 스트림에도 도착합니다. 이 파일의 3개의 채널 특정 부분:
  • Server 생성자의 기능: claude/channel은 알림 리스너를 등록하고, claude/channel/permission은 권한 릴레이에 옵트인하며, tools는 Claude가 회신 도구를 발견하도록 합니다.
  • 아웃바운드 경로: reply 도구 핸들러는 Claude가 대화형 응답을 위해 호출하는 것입니다. PermissionRequestSchema 알림 핸들러는 권한 대화가 열릴 때 Claude Code가 호출하는 것입니다. 둘 다 send()를 호출하여 /events를 통해 브로드캐스트하지만 시스템의 다른 부분에 의해 트리거됩니다.
  • HTTP 핸들러: GET /events는 SSE 스트림을 열어 두므로 curl이 아웃바운드를 실시간으로 볼 수 있습니다. POST는 인바운드이며 X-Sender 헤더에 게이트됩니다. yes <id> 또는 no <id> 본문은 Claude Code로 판정 알림으로 이동하며 Claude에 도달하지 않습니다. 다른 모든 것은 채널 이벤트로 Claude로 전달됩니다.

플러그인으로 패키징

채널을 설치 가능하고 공유 가능하게 하려면 플러그인으로 래핑하고 마켓플레이스에 게시합니다. 사용자는 /plugin install로 설치한 다음 --channels plugin:<name>@<marketplace>로 세션별로 활성화합니다. 자신의 마켓플레이스에 게시된 채널은 승인된 허용 목록에 없으므로 여전히 --dangerously-load-development-channels를 실행해야 합니다. 추가되려면 공식 마켓플레이스에 제출합니다. 채널 플러그인은 승인되기 전에 보안 검토를 거칩니다. 팀 및 엔터프라이즈 계획에서 관리자는 대신 조직의 자신의 allowedChannelPlugins 목록에 플러그인을 포함할 수 있으며, 이는 기본 Anthropic 허용 목록을 대체합니다.

참고 항목

  • 채널을 설치하고 Telegram, Discord, iMessage 또는 fakechat 데모를 사용하며 팀 또는 엔터프라이즈 조직에 대해 채널을 활성화합니다
  • 작동하는 채널 구현은 페어링 흐름, 회신 도구 및 파일 첨부가 있는 완전한 서버 코드입니다
  • MCP는 채널 서버가 구현하는 기본 프로토콜입니다
  • 플러그인을 사용하여 채널을 패키징하면 사용자가 /plugin install로 설치할 수 있습니다