Channels:基于 MCP 的外部事件异步注入机制
核心概念
Claude Code 的 Channels(通道)并不是远程控制的替代方案,也不是双向会话桥。它的本质是一种事件注入协议:允许一个经过授权的 MCP Server 向当前活跃的 Claude Code 会话推送一条带标签的“外部来信”。
在这种机制下:
- 外部消息并不直接夺取终端的控制权,也不会立即打断模型当前的思考。
- 它只是被包装成一个
<channel>标签,并以“下一轮 Prompt”的形式排进本地 REPL 的消息队列。 - 模型在处理完当前任务后,会看到这条新消息,并自行决定是回复、执行工具、还是忽略。
压成一句话:Channels 不是双向遥控器,而是 MCP 驱动的、带来源标签的外部事件排队注入层。
源码级拆解
Channels 的实现依赖于严格的安全准入、消息包装和异步入队三个核心环节:
1. 声明式准入与多层 Gate 裁决
claude-code-opensource/src/main.tsx 要求用户在启动时通过 --channels 参数明确声明本轮会话愿意监听谁(例如 plugin:slack@marketplace 或 server:custom)。 随后,在 claude-code-opensource/src/services/mcp/channelNotification.ts 中,系统会执行一套极其严格的 Gate 裁决:
- Capability 校验:MCP Server 必须明确声明支持
experimental['claude/channel']。 - 组织策略审计:Team 或 Enterprise 用户的 Managed Settings 必须显式开启
channelsEnabled。 - 白名单核验:通过
channelAllowlist.ts比对 GrowthBook 名单或组织自定义的插件允许列表。
2. 消息的结构化包装与标签注入
一旦授权通过,claude-code-opensource/src/services/mcp/useManageMCPConnections.ts 就会注册 notifications/claude/channel 处理器。当外部事件流入时,Claude Code 会对其进行“隔离式”包装:
- 将纯文本包装为
<channel source="..."> ... </channel>的 XML 结构。 - 过滤掉不安全的 Meta 信息,仅保留允许的 Metadata 作为 XML 属性。
- 赋予该消息
priority: 'next'优先级,确保它排在输入队列的最前方。
3. 独立且结构化的权限回传
Channel 消息通常涉及到远端操作审批。claude-code-opensource/src/services/mcp/channelPermissions.ts 设计了一套独立的协议:
- 系统不从普通聊天文本中正则匹配“Yes/No”。
- 它通过
notifications/claude/channel/permission_request发起结构化请求。 - 只有收到格式化的回复事件(
notifications/claude/channel/permission),Claude Code 才会认为这是一次有效的授权回传。
4. IDE 路径下的特化处理
在 claude-code-opensource/src/cli/print.ts(即 IDE 插件调用的核心入口)中,系统仅注册了消息注入处理器,而故意未注册权限回复处理器。这是因为在非交互式或非 CLI 环境下,系统无法直接弹出本地对话框供远程解析,此时开发者需要自行实现权限挂起(Pending Map)逻辑。
实战注意事项
- 会话存活依赖:Channels 只能将消息送入“当前开着的会话”。如果 Claude Code 进程已经退出,Channel Server 无法异步拉起会话。
- OAuth 强制性:Channels 功能仅限于 Claude.ai OAuth 登录用户,API Key 模式下无法使用该功能。
- 非指令执行:注入的消息被标记为
skipSlashCommands: true。这意味着外部推来的/rm -rf /只会被模型当作一段文本看到,而不会作为本地 Slash Command 被直接执行。 - 重连后的重绑定:在
print.ts中,Channel 处理逻辑会在 MCP 掉线重连后执行reregisterChannelHandlerAfterReconnect,以防止消息监听在网络波动后静默掉线。 - 安全隔离:模型对 Channel 消息的感知被限制在 XML 标签内,这有助于模型区分当前输入是来自人类用户还是来自外部系统的自动推送。
延伸阅读
- 自定义 Channel 插件开发:查看
claude-code-opensource/src/services/mcp/channelNotification.ts,了解自定义 Server 该如何声明 Capability。 - 白名单动态更新:研究
claude-code-opensource/src/services/mcp/channelAllowlist.ts,看看 GrowthBook 如何在运行时动态推送到本地允许列表。 - 权限回传协议:阅读
claude-code-opensource/src/services/mcp/channelPermissions.ts,了解结构化权限请求的完整字段定义。
源码锚点
claude-code-opensource/src/services/mcp/channelNotification.ts: Channel 消息的 Schema 定义、 Gate 准入流程与 XML 包装逻辑。
📄 src/services/mcp/channelNotification.ts — Channel 消息的 Schema 定义、 Gate 准入流程与 XML 包装逻辑。
* Channel notifications — lets an MCP server push user messages into the
* conversation. A "channel" (Discord, Slack, SMS, etc.) is just an MCP server
* that:
* - exposes tools for outbound messages (e.g. `send_message`) — standard MCP
* - sends `notifications/claude/channel` notifications for inbound — this file
*
* The notification handler wraps the content in a <channel> tag and
* enqueues it. SleepTool polls hasCommandsInQueue() and wakes within 1s.
* The model sees where the message came from and decides which tool to reply
* with (the channel's MCP tool, SendUserMessage, or both).
*
* feature('KAIROS') || feature('KAIROS_CHANNELS'). Runtime gate tengu_harbor.
* Requires claude.ai OAuth auth — API key users are blocked until
* console gets a channelsEnabled admin surface. Teams/Enterprise orgs
* must explicitly opt in via channelsEnabled: true in managed settings.
*/
import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js'claude-code-opensource/src/services/mcp/useManageMCPConnections.ts: 交互模式下 Channel Handler 的注册点与入队优先级管理。
📄 src/services/mcp/useManageMCPConnections.ts — 交互模式下 Channel Handler 的注册点与入队优先级管理。
ChannelMessageNotificationSchema,
ChannelPermissionNotificationSchema,
findChannelEntry,
gateChannelServer,
wrapChannelMessage,
} from './channelNotification.js'
import {claude-code-opensource/src/services/mcp/channelPermissions.ts: 结构化权限回传协议的详细实现。
📄 src/services/mcp/channelPermissions.ts — 结构化权限回传协议的详细实现。
export function isChannelPermissionRelayEnabled(): boolean {
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false)
}claude-code-opensource/src/services/mcp/channelAllowlist.ts: 组织策略与 GrowthBook 驱动的插件允许列表管理。
📄 src/services/mcp/channelAllowlist.ts — 组织策略与 GrowthBook 驱动的插件允许列表管理。
* Lives in GrowthBook so it can be updated without a release.
*
* Plugin-level granularity: if a plugin is approved, all its channel
* servers are. Per-server gating was overengineering — a plugin that
* sprouts a malicious second server is already compromised, and per-server
* entries would break on harmless plugin refactors.
*
* The allowlist check is a pure {marketplace, plugin} comparison againstclaude-code-opensource/src/cli/print.ts: IDE 路径下的 Channel 处理、权限跳过与重连绑定逻辑。
📄 src/cli/print.ts — IDE 路径下的 Channel 处理、权限跳过与重连绑定逻辑。
// Capabilities passthrough with allowlist pre-filter. The IDE reads
// experimental['claude/channel'] to decide whether to show the
// Enable-channel prompt — only echo it if channel_enable would
// actually pass the allowlist. Not a security boundary (the
// handler re-runs the full gate); just avoids dead buttons.
let capabilities: { experimental?: Record<string, unknown> } | undefinedclaude-code-opensource/src/main.tsx:--channels参数的初始化解析点。
📄 src/main.tsx — `--channels` 参数的初始化解析点。
const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js');
const getTeammatePromptAddendum = () => require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js');
const getTeammateModeSnapshot = () => require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js');
/* eslint-enable @typescript-eslint/no-require-imports */
// Dead code elimination: conditional import for COORDINATOR_MODE
/* eslint-disable @typescript-eslint/no-require-imports */
const coordinatorModeModule = feature('COORDINATOR_MODE') ? require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js') : null;
/* eslint-enable @typescript-eslint/no-require-imports */
// Dead code elimination: conditional import for KAIROS (assistant mode)
/* eslint-disable @typescript-eslint/no-require-imports */
const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null;
const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null;
import { relative, resolve } from 'path';