Claude Code扩展工具——Channels

cuixiaogang

在正常的项目开发中,产品经理提需求、开发实现、测试验证、最后上线,这是比较成熟的一套流程,适合处理完整的功能开发。
但在项目进入维护阶段后,很多需求其实只是一些小改动,比如文案调整、配置修改,或者局部逻辑优化。如果这类改动还按照原来的完整流程推进,往往会让简单问题变得处理周期很长,不仅增加开发的沟通和排期成本,也会影响业务侧的使用效率。
与此同时,AI 工具的发展也带来了新的可能。像 Claude 这样的工具,已经具备一定的代码理解和修改能力,能够辅助完成一些相对简单的开发工作。这就引出了一个值得思考的问题:对于维护阶段的大量小需求,是否有机会通过 AI 缩短中间链路,让需求更快落地?
不过,这件事并不能简单理解为“把 Claude 交给产品来用”。一方面,Claude 的使用方式更偏向代码层面,产品同学通常缺少直接操作代码的能力;另一方面,让非研发角色直接进入开发环境,本身也存在较大的管理和安全风险。
所以,问题的关键并不只是工具能力够不够,而是是否能够围绕现有协作方式,设计出一套适合维护场景的流程机制,让 AI 在可控的前提下真正参与到小需求的处理过程中。

整个流程

1. 搭建 ClaudeWeb 平台

开发一个 ClaudeWeb 平台,用来统一管理多个处于维护阶段的项目。产品经理可以在这个平台中选择对应的项目,直接与该项目绑定的 Claude 进行交互,描述需求和修改内容。

2. 打通 ClaudeWeb 与 Claude 的通信

ClaudeWeb 负责在产品经理和 Claude 之间建立通信,把需求准确传递给 Claude,同时接收 Claude 的执行结果和反馈,从而打通产品与项目代码之间的使用壁垒。

3. 由 Claude 在项目内完成代码修改

Claude 在对应项目的运行环境中工作,根据产品经理提出的需求完成代码更新。

同时,它的操作需要受到项目规则和权限范围的限制。为了保证代码质量,可以提前配置好相关的 skills、开发规范、rules、hooks 等机制,用于代码检查、测试执行和开发约束,尽量避免异常修改和不规范代码进入项目。

4. 产品经理在测试环境验收改动

代码修改完成后,产品经理通过测试地址查看效果,确认改动是否符合预期,是否存在问题。验证通过后,再由产品经理在页面上发起提交 Git 的操作。

5. 通过流水线完成发布上线

流水线持续监听 Git 仓库的变更。在检测到新的版本提交后,自动执行后续发布流程,并完成上线。

Claude Channels 简介

这个方案里,最关键也最难的一点,是如何打通开发机上的 Claude 客户端和 ClaudeWeb 平台。只有这条链路建立起来,产品经理在 Web 端提出的需求,才能真正传递到具体项目中的 Claude,并由它完成后续操作。本文要介绍的核心能力,就是这个问题的解决方案:Claude Channels

这是什么?

Claude Channels 本质上是一套基于 MCP(Model Context Protocol)的插件机制。它通过 MCP 协议,在 Claude Code 和外部消息平台之间建立一条通信通道,让原本只在本地开发环境中工作的 Claude,具备远程接收消息、理解指令并返回结果的能力。

可以把它理解为一座桥:

一边连接开发机上的 Claude,另一边连接 ClaudeWeb 这样的上层平台。通过这座桥,平台侧发出的需求和指令可以传递给 Claude,Claude 的执行结果、状态反馈和处理过程也可以再返回到平台侧。

也就是说,Claude Channels 解决的并不是“让 Claude 更聪明”,而是“让 Claude 能接入现有流程”,从而真正成为整个维护链路中的一个可用节点。

消息流转过程

1
2
3
4
5
6
7
8
9
10
11
消息平台(Telegram/Discord/iMessage)

MCP Server(插件)

<channel> 事件包装

Claude Code 会话

本地环境处理(FS/Git/MCP)

通过暴露的工具回复

代码实践

前期准备及各模块的作用

  • 首先需要准备 Claude 客户端,可参考Claude Code基础概念及使用手册,并且 Claude 版本需要为最新版本(v2.1.80+)。
  • GO代码库:用于开发 MCP Server 及 HTTP Server
  • vue-element-admin:用于开发前端
  • android_analysis_platform:这是我们维护阶段的一个项目,用来作为测试
  • 这里我们的 MCP 使用的是 stdio 通信方式,因为 stdio 的方式更加方便,当然也可以使用其他的方式。

消息流转流程
消息流转流程

Channel MCP Server 开发

这里只列出关键的节点代码

  • mcp.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
package channel

import (
"context"
"fmt"
"net/http"
"os"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

type ChannelServer struct {
mcpServer *server.MCPServer
wsManager *WSManager
session *Session
port int
}

func NewChannelServer(port int) *ChannelServer {
sess := NewSession()
wsMgr := NewWSManager(sess)

cs := &ChannelServer{
wsManager: wsMgr,
session: sess,
port: port,
}

mcpSrv := server.NewMCPServer(
"claude-web",
"0.0.1",
server.WithToolCapabilities(false),
server.WithInstructions(
"Messages arrive as <channel source=\"claude-web\" sender=\"...\" chat_id=\"...\">. "+
"Reply with the reply tool, passing the chat_id from the tag. "+
"Only the current session owner can send messages. "+
"Permission prompts are relayed to the web UI for approval.",
),
server.WithExperimental(map[string]any{
"claude/channel": map[string]any{},
"claude/channel/permission": map[string]any{},
}),
)

replyTool := mcp.NewTool("reply",
mcp.WithDescription("Send a message back to the web UI"),
mcp.WithString("chat_id", mcp.Required(), mcp.Description("The conversation to reply in")),
mcp.WithString("text", mcp.Required(), mcp.Description("The message to send")),
)
mcpSrv.AddTool(replyTool, cs.handleReply)

mcpSrv.AddNotificationHandler(
"notifications/claude/channel/permission_request",
cs.handlePermissionRequest,
)

cs.mcpServer = mcpSrv

wsMgr.SetOnChat(cs.handleChatFromBrowser)
wsMgr.SetOnPermissionVerdict(cs.handlePermissionVerdict)
wsMgr.SetOnAllDisconnected(func() {
fmt.Fprintf(os.Stderr, "[channel] All users disconnected, session released\n")
})

return cs
}

func (cs *ChannelServer) Run() error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Upgrade") == "websocket" {
cs.wsManager.HandleWS(w, r)
return
}
w.WriteHeader(200)
w.Write([]byte("Claude Web Channel Server"))
})

httpServer := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", cs.port),
Handler: mux,
}

go func() {
fmt.Fprintf(os.Stderr, "[channel] WebSocket server listening on port %d\n", cs.port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "[channel] HTTP server error: %v\n", err)
os.Exit(1)
}
}()

stdioSrv := server.NewStdioServer(cs.mcpServer)
return stdioSrv.Listen(context.Background(), os.Stdin, os.Stdout)
}

func (cs *ChannelServer) handleReply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := request.GetArguments()
chatID, _ := args["chat_id"].(string)
text, _ := args["text"].(string)

cs.wsManager.Broadcast(WSMessage{
Type: "chat_reply",
Payload: map[string]string{
"message": text,
"from": "claude",
"chat_id": chatID,
},
})

return mcp.NewToolResultText("sent"), nil
}

func (cs *ChannelServer) handleChatFromBrowser(message, sender, chatID string) {
cs.mcpServer.SendNotificationToAllClients(
"notifications/claude/channel",
map[string]any{
"content": message,
"meta": map[string]string{
"sender": sender,
"chat_id": chatID,
},
},
)
}

func (cs *ChannelServer) handlePermissionVerdict(requestID, behavior string) {
cs.mcpServer.SendNotificationToAllClients(
"notifications/claude/channel/permission",
map[string]any{
"request_id": requestID,
"behavior": behavior,
},
)
}

func (cs *ChannelServer) handlePermissionRequest(ctx context.Context, notification mcp.JSONRPCNotification) {
params := notification.Params.AdditionalFields
requestID, _ := params["request_id"].(string)
toolName, _ := params["tool_name"].(string)
description, _ := params["description"].(string)
inputPreview, _ := params["input_preview"].(string)

cs.wsManager.SendToOwner(WSMessage{
Type: "permission_request",
Payload: map[string]string{
"request_id": requestID,
"tool_name": toolName,
"description": description,
"input_preview": inputPreview,
},
})
}
  • session.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package channel

import "sync"

type SessionState struct {
Owner string `json:"owner"`
Users []string `json:"users"`
}

type Session struct {
mu sync.RWMutex
owner string
users map[string]bool
}

func NewSession() *Session {
return &Session{users: make(map[string]bool)}
}

func (s *Session) Claim(user string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.owner != "" && s.owner != user {
return false
}
s.owner = user
return true
}

func (s *Session) Release(user string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.owner != user {
return false
}
s.owner = ""
return true
}

func (s *Session) GetOwner() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.owner
}

func (s *Session) AddUser(user string) {
s.mu.Lock()
defer s.mu.Unlock()
s.users[user] = true
}

func (s *Session) RemoveUser(user string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.users, user)
if s.owner == user {
s.owner = ""
}
}

func (s *Session) GetState() SessionState {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]string, 0, len(s.users))
for u := range s.users {
users = append(users, u)
}
return SessionState{Owner: s.owner, Users: users}
}
  • websocket.go(用于与前端通信)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
package channel

import (
"encoding/json"
"fmt"
"net/http"
"sync"

"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}

type WSMessage struct {
Type string `json:"type"`
Payload map[string]string `json:"payload"`
}

type client struct {
conn *websocket.Conn
user string
}

type WSManager struct {
mu sync.RWMutex
clients map[*websocket.Conn]*client
session *Session
onChat func(message, sender, chatID string)
onPermissionVerdict func(requestID, behavior string)
onAllDisconnected func()
}

func NewWSManager(session *Session) *WSManager {
return &WSManager{
clients: make(map[*websocket.Conn]*client),
session: session,
}
}

func (wm *WSManager) SetOnChat(fn func(message, sender, chatID string)) {
wm.onChat = fn
}

func (wm *WSManager) SetOnPermissionVerdict(fn func(requestID, behavior string)) {
wm.onPermissionVerdict = fn
}

func (wm *WSManager) SetOnAllDisconnected(fn func()) {
wm.onAllDisconnected = fn
}

func (wm *WSManager) HandleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() {
wm.removeClient(conn)
conn.Close()
}()

for {
_, raw, err := conn.ReadMessage()
if err != nil {
break
}
var msg WSMessage
if err := json.Unmarshal(raw, &msg); err != nil {
wm.sendTo(conn, WSMessage{Type: "error", Payload: map[string]string{"message": "invalid JSON"}})
continue
}
wm.handleMessage(conn, msg)
}
}

func (wm *WSManager) handleMessage(conn *websocket.Conn, msg WSMessage) {
switch msg.Type {
case "register":
user := msg.Payload["user"]
if user == "" {
return
}
wm.mu.Lock()
wm.clients[conn] = &client{conn: conn, user: user}
wm.mu.Unlock()
wm.session.AddUser(user)
wm.BroadcastStatus()

case "chat":
wm.mu.RLock()
c := wm.clients[conn]
wm.mu.RUnlock()
if c == nil {
return
}
sender := msg.Payload["sender"]
if sender == "" {
sender = c.user
}
owner := wm.session.GetOwner()
if owner != "" && owner != sender {
wm.sendTo(conn, WSMessage{Type: "error", Payload: map[string]string{"message": fmt.Sprintf("当前被 %s 占用", owner)}})
return
}
chatID := fmt.Sprintf("%d", nextChatID())
wm.Broadcast(WSMessage{Type: "chat_echo", Payload: map[string]string{"message": msg.Payload["message"], "sender": sender, "chat_id": chatID}})
if wm.onChat != nil {
wm.onChat(msg.Payload["message"], sender, chatID)
}

case "claim":
wm.mu.RLock()
c := wm.clients[conn]
wm.mu.RUnlock()
if c == nil {
return
}
user := msg.Payload["user"]
if user == "" {
user = c.user
}
if wm.session.Claim(user) {
wm.BroadcastStatus()
} else {
wm.sendTo(conn, WSMessage{Type: "error", Payload: map[string]string{"message": fmt.Sprintf("当前被 %s 占用", wm.session.GetOwner())}})
}

case "release":
wm.mu.RLock()
c := wm.clients[conn]
wm.mu.RUnlock()
if c == nil {
return
}
user := msg.Payload["user"]
if user == "" {
user = c.user
}
if wm.session.Release(user) {
wm.BroadcastStatus()
}

case "permission_verdict":
requestID := msg.Payload["request_id"]
behavior := msg.Payload["behavior"]
if requestID == "" || behavior == "" {
return
}
wm.mu.RLock()
c := wm.clients[conn]
wm.mu.RUnlock()
if c == nil {
return
}
if c.user != wm.session.GetOwner() {
wm.sendTo(conn, WSMessage{Type: "error", Payload: map[string]string{"message": "只有占用者可以审批权限"}})
return
}
if wm.onPermissionVerdict != nil {
wm.onPermissionVerdict(requestID, behavior)
}
}
}

func (wm *WSManager) Broadcast(msg WSMessage) {
data, _ := json.Marshal(msg)
wm.mu.RLock()
defer wm.mu.RUnlock()
for conn := range wm.clients {
conn.WriteMessage(websocket.TextMessage, data)
}
}

func (wm *WSManager) SendToOwner(msg WSMessage) {
owner := wm.session.GetOwner()
if owner == "" {
return
}
data, _ := json.Marshal(msg)
wm.mu.RLock()
defer wm.mu.RUnlock()
for _, c := range wm.clients {
if c.user == owner {
c.conn.WriteMessage(websocket.TextMessage, data)
}
}
}

func (wm *WSManager) BroadcastStatus() {
state := wm.session.GetState()
wm.Broadcast(WSMessage{
Type: "status",
Payload: map[string]string{
"owner": state.Owner,
"users": mustJSON(state.Users),
},
})
}

func (wm *WSManager) removeClient(conn *websocket.Conn) {
wm.mu.Lock()
c, ok := wm.clients[conn]
delete(wm.clients, conn)
remaining := len(wm.clients)
wm.mu.Unlock()
if ok && c.user != "" {
wm.session.RemoveUser(c.user)
wm.BroadcastStatus()
}
if remaining == 0 && wm.onAllDisconnected != nil {
wm.onAllDisconnected()
}
}

func (wm *WSManager) sendTo(conn *websocket.Conn, msg WSMessage) {
data, _ := json.Marshal(msg)
conn.WriteMessage(websocket.TextMessage, data)
}

var chatIDCounter uint64
var chatIDMu sync.Mutex

func nextChatID() uint64 {
chatIDMu.Lock()
defer chatIDMu.Unlock()
chatIDCounter++
return chatIDCounter
}

func mustJSON(v interface{}) string {
data, _ := json.Marshal(v)
return string(data)
}

android_analysis_platform 注册自定义 MCP 插件

在项目下创建.mcp.json文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"mcpServers": {
"claude-web": {
"args": [
"channel"
],
// 启动 GO MCP Server 的命令
"command": "/data/cuixiaogang/service/data/claude_web/server/claude-web-server",
"env": {
// 端口号(可改)
"CHANNEL_PORT": "15422"
},
"type": "stdio"
}
}
}

执行以下命令即可启动 MCP Server :

1
claude --dangerously-load-development-channels server:claude-web

如果不期望 Claude Cli 一直打开这,不能停止,可以使用tmux来启动 Claude

前端代码

前端代码就是一个聊天窗口,不过需要支持收取事件授权(只贴出来部分代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
_doConnect() {
if (!this.url) return

this.ws = new WebSocket(this.url)

this.ws.onopen = () => {
this.reconnectAttempts = 0
this.store.commit('claude/SET_WS_CONNECTED', true)
this.send({type: WS_MSG_TYPE.REGISTER, payload: {user: this.user}})
}

this.ws.onmessage = (event) => {
let msg
try {
msg = JSON.parse(event.data)
} catch (e) {
return
}
this._handleMessage(msg)
}

this.ws.onclose = () => {
this.store.commit('claude/SET_WS_CONNECTED', false)
this._scheduleReconnect()
}

this.ws.onerror = () => {
// onclose will fire after onerror
}
}

_handleMessage(msg) {
switch (msg.type) {
case WS_MSG_TYPE.CHAT_REPLY:
this.store.commit('claude/ADD_MESSAGE', {
role: 'claude',
content: msg.payload.message,
timestamp: Date.now()
})
break
case WS_MSG_TYPE.CHAT_ECHO:
this.store.commit('claude/ADD_MESSAGE', {
role: 'user',
content: msg.payload.message,
sender: msg.payload.sender,
timestamp: Date.now()
})
break
case WS_MSG_TYPE.STATUS:
this.store.commit('claude/SET_SESSION_STATUS', msg.payload)
break
case WS_MSG_TYPE.PERMISSION_REQUEST:
this.store.commit('claude/ADD_PERMISSION_REQUEST', msg.payload)
break
case WS_MSG_TYPE.ERROR:
this.store.commit('claude/SET_ERROR', msg.payload.message)
break
}
}

最终成果