์ฑ๋ ์ฐธ์กฐ
์นํ , ์๋ฆผ, ์ฑํ ๋ฉ์์ง๋ฅผ Claude Code ์ธ์ ์ผ๋ก ํธ์ํ๋ MCP ์๋ฒ๋ฅผ ๊ตฌ์ถํฉ๋๋ค. ์ฑ๋ ๊ณ์ฝ ์ฐธ์กฐ: ๊ธฐ๋ฅ ์ ์ธ, ์๋ฆผ ์ด๋ฒคํธ, ํ์ ๋๊ตฌ, ๋ฐ์ ์ ๊ฒ์ดํ , ๊ถํ ๋ฆด๋ ์ด.
์ฑ๋์ ์ฐ๊ตฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ ์์ผ๋ฉฐ Claude Code v2.1.80 ์ด์์ด ํ์ํฉ๋๋ค. claude.ai ๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค. ์ฝ์ ๋ฐ API ํค ์ธ์ฆ์ ์ง์๋์ง ์์ต๋๋ค. ํ ๋ฐ ์ํฐํ๋ผ์ด์ฆ ์กฐ์ง์ ๋ช ์์ ์ผ๋ก ํ์ฑํํด์ผ ํฉ๋๋ค.
์ฑ๋์ Claude Code ์ธ์ ์ผ๋ก ์ด๋ฒคํธ๋ฅผ ํธ์ํ๋ MCP ์๋ฒ์ด๋ฏ๋ก Claude๋ ํฐ๋ฏธ๋ ์ธ๋ถ์์ ๋ฐ์ํ๋ ์ผ์ ๋ฐ์ํ ์ ์์ต๋๋ค.
๋จ๋ฐฉํฅ ๋๋ ์๋ฐฉํฅ ์ฑ๋์ ๊ตฌ์ถํ ์ ์์ต๋๋ค. ๋จ๋ฐฉํฅ ์ฑ๋์ 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๋ก ํธ์ํฉ๋๋ค.
ํ์ํ ๊ฒ
์ ์ผํ ํ๋ ์๊ตฌ ์ฌํญ์ @modelcontextprotocol/sdk ํจํค์ง์ Node.js ํธํ ๋ฐํ์์
๋๋ค. Bun, Node, Deno ๋ชจ๋ ์๋ํฉ๋๋ค. ์ฐ๊ตฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์ ์ฌ์ ๊ตฌ์ถ๋ ํ๋ฌ๊ทธ์ธ์ Bun์ ์ฌ์ฉํ์ง๋ง ์ฑ๋์ด ๋ฐ๋์ ๊ทธ๋ด ํ์๋ ์์ต๋๋ค.
์๋ฒ๋ ๋ค์์ ์ํํด์ผ ํฉ๋๋ค:
claude/channel๊ธฐ๋ฅ์ ์ ์ธํ์ฌ Claude Code๊ฐ ์๋ฆผ ๋ฆฌ์ค๋๋ฅผ ๋ฑ๋กํ๋๋ก ํจ- ๋ฌด์ธ๊ฐ ๋ฐ์ํ ๋
notifications/claude/channel์ด๋ฒคํธ๋ฅผ ๋ด๋ณด๋ - stdio ์ ์ก์ ํตํด ์ฐ๊ฒฐ (Claude Code๊ฐ ์๋ฒ๋ฅผ ์๋ธํ๋ก์ธ์ค๋ก ์์ฑ)
์๋ฒ ์ต์ ๋ฐ ์๋ฆผ ํ์ ์น์ ์์ ๊ฐ๊ฐ์ ์์ธํ ๋ค๋ฃน๋๋ค. ์ ์ฒด ์ฐ์ต์ ์: ์นํ ์์ ๊ธฐ ๊ตฌ์ถ์ ์ฐธ์กฐํ์ธ์.
์ฐ๊ตฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค์ ์ฌ์ฉ์ ์ ์ ์ฑ๋์ ์น์ธ๋ ํ์ฉ ๋ชฉ๋ก์ ์์ต๋๋ค. --dangerously-load-development-channels๋ฅผ ์ฌ์ฉํ์ฌ ๋ก์ปฌ์์ ํ
์คํธํฉ๋๋ค. ์์ธํ ๋ด์ฉ์ ์ฐ๊ตฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค ํ
์คํธ๋ฅผ ์ฐธ์กฐํ์ธ์.
์: ์นํ ์์ ๊ธฐ ๊ตฌ์ถ
์ด ์ฐ์ต์ HTTP ์์ฒญ์ ์์ ํ๊ณ Claude Code ์ธ์
์ผ๋ก ์ ๋ฌํ๋ ๋จ์ผ ํ์ผ ์๋ฒ๋ฅผ ๊ตฌ์ถํฉ๋๋ค. ๋ง์ง๋ง์๋ CI ํ์ดํ๋ผ์ธ, ๋ชจ๋ํฐ๋ง ์๋ฆผ ๋๋ curl ๋ช
๋ น๊ณผ ๊ฐ์ด HTTP POST๋ฅผ ๋ณด๋ผ ์ ์๋ ๋ชจ๋ ๊ฒ์ด Claude๋ก ์ด๋ฒคํธ๋ฅผ ํธ์ํ ์ ์์ต๋๋ค.
์ด ์์ ๋ ๊ธฐ๋ณธ ์ ๊ณต HTTP ์๋ฒ ๋ฐ TypeScript ์ง์์ ์ํด Bun์ ๋ฐํ์์ผ๋ก ์ฌ์ฉํฉ๋๋ค. ๋์ Node ๋๋ Deno๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค. ์ ์ผํ ์๊ตฌ ์ฌํญ์ MCP SDK์ ๋๋ค.
ํ๋ก์ ํธ ์์ฑ
์ ๋๋ ํ ๋ฆฌ๋ฅผ ์์ฑํ๊ณ MCP SDK๋ฅผ ์ค์นํฉ๋๋ค:
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
์ฑ๋ ์๋ฒ ์์ฑ
webhook.ts๋ผ๋ ํ์ผ์ ์์ฑํฉ๋๋ค. ์ด๊ฒ์ด ์ ์ฒด ์ฑ๋ ์๋ฒ์
๋๋ค: stdio๋ฅผ ํตํด Claude Code์ ์ฐ๊ฒฐ๋๊ณ ํฌํธ 8788์์ HTTP POST๋ฅผ ์์ ํฉ๋๋ค. ์์ฒญ์ด ๋์ฐฉํ๋ฉด ๋ณธ๋ฌธ์ ์ฑ๋ ์ด๋ฒคํธ๋ก Claude๋ก ํธ์ํฉ๋๋ค.
#!/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์ธ์คํด์ค์ ์ก์ธ์คํด์ผ ํ๋ฏ๋ก ๋์ผํ ํ๋ก์ธ์ค์์ ์คํ๋ฉ๋๋ค. ๋ ํฐ ํ๋ก์ ํธ์ ๊ฒฝ์ฐ ๋ณ๋์ ๋ชจ๋๋ก ๋ถํ ํ ์ ์์ต๋๋ค.
Claude Code์ ์๋ฒ ๋ฑ๋ก
Claude Code๊ฐ ์์ํ๋ ๋ฐฉ๋ฒ์ ์ ์ ์๋๋ก MCP ๊ตฌ์ฑ์ ์๋ฒ๋ฅผ ์ถ๊ฐํฉ๋๋ค. ๋์ผํ ๋๋ ํ ๋ฆฌ์ ํ๋ก์ ํธ ์์ค .mcp.json์ ๊ฒฝ์ฐ ์๋ ๊ฒฝ๋ก๋ฅผ ์ฌ์ฉํฉ๋๋ค. ~/.claude.json์ ์ฌ์ฉ์ ์์ค ๊ตฌ์ฑ์ ๊ฒฝ์ฐ ๋ชจ๋ ํ๋ก์ ํธ์์ ์๋ฒ๋ฅผ ์ฐพ์ ์ ์๋๋ก ์ ์ฒด ์ ๋ ๊ฒฝ๋ก๋ฅผ ์ฌ์ฉํฉ๋๋ค:
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}
Claude Code๋ ์์ ์ MCP ๊ตฌ์ฑ์ ์ฝ๊ณ ๊ฐ ์๋ฒ๋ฅผ ์๋ธํ๋ก์ธ์ค๋ก ์์ฑํฉ๋๋ค.
ํ ์คํธ
์ฐ๊ตฌ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค์ ์ฌ์ฉ์ ์ ์ ์ฑ๋์ ํ์ฉ ๋ชฉ๋ก์ ์์ผ๋ฏ๋ก ๊ฐ๋ฐ ํ๋๊ทธ๋ก 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 ์์ฑ์์์ ์ด๋ฌํ ์ต์
์ ์ค์ ํฉ๋๋ค. instructions ๋ฐ capabilities.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.tools |
object |
์๋ฐฉํฅ๋ง. ํญ์ {}. ํ์ค MCP ๋๊ตฌ ๊ธฐ๋ฅ. ํ์ ๋๊ตฌ ๋
ธ์ถ์ ์ฐธ์กฐํ์ธ์. |
instructions |
string |
๊ถ์ฅ. 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์ ๋ด๋ณด๋
๋๋ค:
| ํ๋ | ์ ํ | ์ค๋ช |
|---|---|---|
content |
string |
์ด๋ฒคํธ ๋ณธ๋ฌธ. <channel> ํ๊ทธ์ ๋ณธ๋ฌธ์ผ๋ก ์ ๋ฌ๋ฉ๋๋ค. |
meta |
Record<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 ๋๊ตฌ๋ฅผ ๋ ธ์ถํฉ๋๋ค. ๋๊ตฌ ๋ฑ๋ก์ ๋ํด ์ฑ๋ ํน์ ์ฌํญ์ ์์ต๋๋ค. ํ์ ๋๊ตฌ์๋ ์ธ ๊ฐ์ง ๊ตฌ์ฑ ์์๊ฐ ์์ต๋๋ค:
Server์์ฑ์ ๊ธฐ๋ฅ์tools: {}ํญ๋ชฉ์ด๋ฏ๋ก Claude Code๊ฐ ๋๊ตฌ๋ฅผ ๋ฐ๊ฒฌํฉ๋๋ค- ๋๊ตฌ์ ์คํค๋ง๋ฅผ ์ ์ํ๊ณ ์ ์ก ๋ก์ง์ ๊ตฌํํ๋ ๋๊ตฌ ํธ๋ค๋ฌ
- Claude์ ๋๊ตฌ๋ฅผ ํธ์ถํ ์๊ธฐ์ ๋ฐฉ๋ฒ์ ์๋ ค์ฃผ๋
Server์์ฑ์์instructions๋ฌธ์์ด
์์ ์นํ ์์ ๊ธฐ์ ์ด๋ฅผ ์ถ๊ฐํ๋ ค๋ฉด:
๋๊ตฌ ๋ฐ๊ฒฌ ํ์ฑํ
webhook.ts์ Server ์์ฑ์์์ Claude Code๊ฐ ์๋ฒ๊ฐ ๋๊ตฌ๋ฅผ ์ ๊ณตํจ์ ์ ์ ์๋๋ก ๊ธฐ๋ฅ์ tools: {}๋ฅผ ์ถ๊ฐํฉ๋๋ค:
capabilities: {
experimental: { 'claude/channel': {} },
tools: {}, // ๋๊ตฌ ๋ฐ๊ฒฌ์ ํ์ฑํํฉ๋๋ค
},
ํ์ ๋๊ตฌ ๋ฑ๋ก
๋ค์์ webhook.ts์ ์ถ๊ฐํฉ๋๋ค. import๋ ๋ค๋ฅธ ๊ฐ์ ธ์ค๊ธฐ์ ํจ๊ป ํ์ผ ๋งจ ์๋ก ์ด๋ํฉ๋๋ค. ๋ ํธ๋ค๋ฌ๋ Server ์์ฑ์์ mcp.connect() ์ฌ์ด์ ์์ต๋๋ค. ์ด๊ฒ์ Claude๊ฐ chat_id ๋ฐ text๋ก ํธ์ถํ ์ ์๋ 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}`)
})
์ง์นจ ์ ๋ฐ์ดํธ
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๊ฐ ์๋๋๋ค. ๊ทธ๋ฃน ์ฑํ
์์ ์ด๋ค์ ๋ค๋ฅด๋ฉฐ ๋ฐฉ์ ๊ฒ์ดํธํ๋ฉด ํ์ฉ ๋ชฉ๋ก์ ์๋ ๊ทธ๋ฃน์ ๋ชจ๋ ์ฌ๋์ด ์ธ์
์ ๋ฉ์์ง๋ฅผ ์ฃผ์
ํ ์ ์์ต๋๋ค.
Telegram ๋ฐ Discord ์ฑ๋์ ๋์ผํ ๋ฐฉ์์ผ๋ก ๋ฐ์ ์ ํ์ฉ ๋ชฉ๋ก์ ๊ฒ์ดํธํฉ๋๋ค. ํ์ด๋ง์ผ๋ก ๋ชฉ๋ก์ ๋ถํธ์คํธ๋ฉํฉ๋๋ค: ์ฌ์ฉ์๊ฐ ๋ด์ DM์ ๋ณด๋ด๋ฉด ๋ด์ด ํ์ด๋ง ์ฝ๋๋ก ํ์ ํ๊ณ ์ฌ์ฉ์๊ฐ Claude Code ์ธ์ ์์ ์น์ธํ๋ฉฐ ํ๋ซํผ ID๊ฐ ์ถ๊ฐ๋ฉ๋๋ค. ์ ์ฒด ํ์ด๋ง ํ๋ฆ์ ๊ตฌํ ์ค ํ๋๋ฅผ ์ฐธ์กฐํ์ธ์. iMessage ์ฑ๋์ ๋ค๋ฅธ ์ ๊ทผ ๋ฐฉ์์ ์ทจํฉ๋๋ค: ์์ ์ ๋ฉ์์ง ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ฌ์ฉ์์ ์์ ์ ์ฃผ์๋ฅผ ๊ฐ์งํ๊ณ ์๋์ผ๋ก ํต๊ณผ์ํค๋ฉฐ ๋ค๋ฅธ ๋ฐ์ ์๋ ํธ๋ค๋ก ์ถ๊ฐ๋ฉ๋๋ค.
๊ถํ ํ๋กฌํํธ ๋ฆด๋ ์ด
๊ถํ ๋ฆด๋ ์ด๋ Claude Code v2.1.81 ์ด์์ด ํ์ํฉ๋๋ค. ์ด์ ๋ฒ์ ์ claude/channel/permission ๊ธฐ๋ฅ์ ๋ฌด์ํฉ๋๋ค.
Claude๊ฐ ์น์ธ์ด ํ์ํ ๋๊ตฌ๋ฅผ ํธ์ถํ ๋ ๋ก์ปฌ ํฐ๋ฏธ๋ ๋ํ๊ฐ ์ด๋ฆฌ๊ณ ์ธ์ ์ด ๋๊ธฐํฉ๋๋ค. ์๋ฐฉํฅ ์ฑ๋์ ๋์ผํ ํ๋กฌํํธ๋ฅผ ๋ณ๋ ฌ๋ก ์์ ํ๊ณ ๋ค๋ฅธ ์ฅ์น์ ์ฌ์ฉ์์๊ฒ ๋ฆด๋ ์ดํ๋๋ก ์ ํํ ์ ์์ต๋๋ค. ๋ ๋ค ํ์ฑ ์ํ๋ก ์ ์ง๋ฉ๋๋ค: ํฐ๋ฏธ๋ ๋๋ ํด๋ํฐ์์ ๋ต๋ณํ ์ ์์ผ๋ฉฐ Claude Code๋ ๋จผ์ ๋์ฐฉํ๋ ๋ต๋ณ์ ์ ์ฉํ๊ณ ๋ค๋ฅธ ๋ต๋ณ์ ๋ซ์ต๋๋ค.
๋ฆด๋ ์ด๋ Bash, Write ๋ฐ Edit๊ณผ ๊ฐ์ ๋๊ตฌ ์ฌ์ฉ ์น์ธ์ ๋ค๋ฃน๋๋ค. ํ๋ก์ ํธ ์ ๋ขฐ ๋ฐ MCP ์๋ฒ ๋์ ๋ํ๋ ๋ฆด๋ ์ด๋์ง ์์ต๋๋ค. ์ด๋ค์ ๋ก์ปฌ ํฐ๋ฏธ๋์๋ง ๋ํ๋ฉ๋๋ค.
๋ฆด๋ ์ด ์๋ ๋ฐฉ์
๊ถํ ํ๋กฌํํธ๊ฐ ์ด๋ฆฌ๋ฉด ๋ฆด๋ ์ด ๋ฃจํ์๋ ๋ค ๊ฐ์ง ๋จ๊ณ๊ฐ ์์ต๋๋ค:
- Claude Code๋ ์งง์ ์์ฒญ ID๋ฅผ ์์ฑํ๊ณ ์๋ฒ์ ์๋ฆฝ๋๋ค
- ์๋ฒ๋ ํ๋กฌํํธ ๋ฐ ID๋ฅผ ์ฑํ ์ฑ์ผ๋ก ์ ๋ฌํฉ๋๋ค
- ์๊ฒฉ ์ฌ์ฉ์๊ฐ ์ ๋๋ ์๋์ค๋ก ํด๋น ID๋ก ํ์ ํฉ๋๋ค
- ์ธ๋ฐ์ด๋ ํธ๋ค๋ฌ๋ ํ์ ์ ํ์ ์ผ๋ก ๊ตฌ๋ฌธ ๋ถ์ํ๊ณ Claude Code๋ ID๊ฐ ์ด๋ฆฐ ์์ฒญ๊ณผ ์ผ์นํ๋ ๊ฒฝ์ฐ์๋ง ์ ์ฉํฉ๋๋ค
๋ก์ปฌ ํฐ๋ฏธ๋ ๋ํ๋ ์ด ๋ชจ๋ ๊ณผ์ ์ ํตํด ์ด๋ ค ์์ต๋๋ค. ํฐ๋ฏธ๋์ ๋๊ตฐ๊ฐ๊ฐ ์๊ฒฉ ํ์ ์ด ๋์ฐฉํ๊ธฐ ์ ์ ๋ต๋ณํ๋ฉด ํด๋น ๋ต๋ณ์ด ๋์ ์ ์ฉ๋๊ณ ๋ณด๋ฅ ์ค์ธ ์๊ฒฉ ์์ฒญ์ด ์ญ์ ๋ฉ๋๋ค.
๊ถํ ์์ฒญ ํ๋
Claude Code์ ์์๋ฐ์ด๋ ์๋ฆผ์ notifications/claude/channel/permission_request์
๋๋ค. ์ฑ๋ ์๋ฆผ๊ณผ ๊ฐ์ด ์ ์ก์ ํ์ค MCP์ด์ง๋ง ๋ฉ์๋ ๋ฐ ์คํค๋ง๋ Claude Code ํ์ฅ์
๋๋ค. params ๊ฐ์ฒด์๋ ์๋ฒ๊ฐ ์์๋ฐ์ด๋ ํ๋กฌํํธ๋ก ํฌ๋งทํ๋ ๋ค ๊ฐ์ ๋ฌธ์์ด ํ๋๊ฐ ์์ต๋๋ค:
| ํ๋ | ์ค๋ช |
|---|---|
request_id |
a-z์์ ๊ทธ๋ ค์ง 5๊ฐ์ ์๋ฌธ์์ด๋ฉฐ l์ ์ ์ธํ๋ฏ๋ก ํด๋ํฐ์ ์
๋ ฅํ ๋ 1 ๋๋ I๋ก ์ฝํ์ง ์์ต๋๋ค. ์์๋ฐ์ด๋ ํ๋กฌํํธ์ ํฌํจํ์ฌ ํ์ ์์ ์์ฝํ ์ ์๋๋ก ํฉ๋๋ค. Claude Code๋ ๋ฐ๊ธํ ID๋ฅผ ๊ฐ์ง ํ์ ๋ง ์๋ฝํฉ๋๋ค. ๋ก์ปฌ ํฐ๋ฏธ๋ ๋ํ๋ ์ด ID๋ฅผ ํ์ํ์ง ์์ผ๋ฏ๋ก ์์๋ฐ์ด๋ ํธ๋ค๋ฌ๊ฐ ์ด๋ฅผ ํ์ตํ๋ ์ ์ผํ ๋ฐฉ๋ฒ์
๋๋ค. |
tool_name |
Claude๊ฐ ์ฌ์ฉํ๋ ค๋ ๋๊ตฌ์ ์ด๋ฆ(์: Bash ๋๋ Write). |
description |
์ด ํน์ ๋๊ตฌ ํธ์ถ์ด ์ํํ๋ ์์ ์ ์ธ๊ฐ์ด ์ฝ์ ์ ์๋ ์์ฝ์ด๋ฉฐ ๋ก์ปฌ ํฐ๋ฏธ๋ ๋ํ๊ฐ ํ์ํ๋ ๋์ผํ ํ ์คํธ์ ๋๋ค. Bash ํธ์ถ์ ๊ฒฝ์ฐ ์ด๋ Claude์ ๋ช ๋ น ์ค๋ช ์ด๊ฑฐ๋ ์ฃผ์ด์ง ๊ฒ์ด ์์ผ๋ฉด ๋ช ๋ น ์์ฒด์ ๋๋ค. |
input_preview |
๋๊ตฌ์ ์ธ์๋ฅผ JSON ๋ฌธ์์ด๋ก 200์๋ก ์๋ฆฐ ๊ฒ์ ๋๋ค. Bash์ ๊ฒฝ์ฐ ๋ช ๋ น์ ๋๋ค. Write์ ๊ฒฝ์ฐ ํ์ผ ๊ฒฝ๋ก ๋ฐ ์ฝํ ์ธ ์ ์ ๋์ฌ์ ๋๋ค. ํ ์ค ๋ฉ์์ง๋ง ๊ณต๊ฐ์ด ์๋ ๊ฒฝ์ฐ ํ๋กฌํํธ์์ ์๋ตํฉ๋๋ค. ์๋ฒ๊ฐ ํ์ํ ๋ด์ฉ์ ๊ฒฐ์ ํฉ๋๋ค. |
์๋ฒ๊ฐ ๋ค์ ๋ณด๋ด๋ ํ์ ์ notifications/claude/channel/permission์ด๋ฉฐ ๋ ํ๋๊ฐ ์์ต๋๋ค: ์์ ID๋ฅผ ์์ฝํ๋ request_id ๋ฐ 'allow' ๋๋ 'deny'๋ก ์ค์ ๋ behavior. Allow๋ ๋๊ตฌ ํธ์ถ์ ์งํํ๋๋ก ํฉ๋๋ค. Deny๋ ์ด๋ฅผ ๊ฑฐ๋ถํ๋ฉฐ ๋ก์ปฌ ๋ํ์์ ์๋์ค๋ก ๋ต๋ณํ๋ ๊ฒ๊ณผ ๋์ผํฉ๋๋ค. ์ด๋ ํ์ ๋ ํฅํ ํธ์ถ์ ์ํฅ์ ์ฃผ์ง ์์ต๋๋ค.
์ฑํ ๋ธ๋ฆฌ์ง์ ๋ฆด๋ ์ด ์ถ๊ฐ
์๋ฐฉํฅ ์ฑ๋์ ๊ถํ ๋ฆด๋ ์ด๋ฅผ ์ถ๊ฐํ๋ ค๋ฉด ์ธ ๊ฐ์ง ๊ตฌ์ฑ ์์๊ฐ ํ์ํฉ๋๋ค:
Server์์ฑ์์experimental๊ธฐ๋ฅ ์๋claude/channel/permission: {}ํญ๋ชฉ์ด๋ฏ๋ก Claude Code๊ฐ ํ๋กฌํํธ๋ฅผ ์ ๋ฌํ๋ ๋ฐฉ๋ฒ์ ์ ์ ์์ต๋๋คnotifications/claude/channel/permission_request์ ๋ํ ์๋ฆผ ํธ๋ค๋ฌ๊ฐ ํ๋กฌํํธ๋ฅผ ํฌ๋งทํ๊ณ ํ๋ซํผ API๋ฅผ ํตํด ์ ์กํฉ๋๋ค- ์ธ๋ฐ์ด๋ ๋ฉ์์ง ํธ๋ค๋ฌ์ ํ์ธ์ด
yes <id>๋๋no <id>๋ฅผ ์ธ์ํ๊ณ ํ ์คํธ๋ฅผ Claude๋ก ์ ๋ฌํ๋ ๋์notifications/claude/channel/permissionํ์ ์ ๋ด๋ณด๋ ๋๋ค
์ฑ๋์ด ๋ฐ์ ์๋ฅผ ์ธ์ฆํ๋ ๊ฒฝ์ฐ์๋ง ๊ธฐ๋ฅ์ ์ ์ธํฉ๋๋ค. ์ฑ๋์ ํตํด ํ์ ํ ์ ์๋ ๋ชจ๋ ์ฌ๋์ด ์ธ์ ์์ ๋๊ตฌ ์ฌ์ฉ์ ์น์ธํ๊ฑฐ๋ ๊ฑฐ๋ถํ ์ ์๊ธฐ ๋๋ฌธ์ ๋๋ค.
ํ์ ๋๊ตฌ ๋ ธ์ถ์์ ์กฐ๋ฆฝ๋ ์๋ฐฉํฅ ์ฑํ ๋ธ๋ฆฌ์ง์ ๊ฐ์ ๊ฒ์ ์ด๋ฅผ ์ถ๊ฐํ๋ ค๋ฉด:
๊ถํ ๊ธฐ๋ฅ ์ ์ธ
Server ์์ฑ์์์ experimental ์๋ claude/channel ์์ claude/channel/permission: {}๋ฅผ ์ถ๊ฐํฉ๋๋ค:
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // ๊ถํ ๋ฆด๋ ์ด์ ์ตํธ์ธํฉ๋๋ค
},
tools: {},
},
๋ค์ด์ค๋ ์์ฒญ ์ฒ๋ฆฌ
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}"`,
)
})
์ธ๋ฐ์ด๋ ํธ๋ค๋ฌ์์ ํ์ ๊ฐ๋ก์ฑ๊ธฐ
์ธ๋ฐ์ด๋ ํธ๋ค๋ฌ๋ ํ๋ซํผ์์ ๋ฉ์์ง๋ฅผ ์์ ํ๋ ๋ฃจํ ๋๋ ์ฝ๋ฐฑ์
๋๋ค: ๋ฐ์ ์์ ๊ฒ์ดํธํ๊ณ 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๋ก ์ค์นํ ์ ์์ต๋๋ค