Files
SimpleRemoter/docs/Mcp_Chat_Design.md
T
yuanyuanxianganddeepseek-flash c99a1c375e Feature: Add MCP tools for remote chat with the client user
Add chat_open, chat_send, chat_wait and chat_close to the MCP tool set,
behind a new McpChat switch that defaults to off and is independent of
the read-only switch. The tools reuse the existing client chat link: the
main connection sends COMMAND_CHAT, the client opens a sub-connection
with TOKEN_CONN_AUTH and TOKEN_CHAT_START, the server answers with
COMMAND_NEXT_CHAT, and both directions then carry raw CP936 text.

Sessions are per host and held in shared pointers under their own mutex,
so a waiting chat_wait survives a close or a dropped sub-connection, and
chat_send is serialised per session. Text is converted strictly between
UTF-8 and CP936: invalid UTF-8, characters GBK cannot represent (emoji,
accented letters) and messages over the client's 4000-byte buffer are
rejected with -32602 instead of being mangled. A one-second settle delay
after the sub-connection binds keeps the first message from reaching the
client before its chat window exists.

The routing hooks run before the host-management hooks in MessageHandle
and clear the route in OfflineProc before the context leaves the list. A
five-second tombstone absorbs a late TOKEN_CHAT_START after a timeout, so
a failed chat_open does not pop the MFC chat dialog. Idle sessions are
swept on every tool call, and audit entries record the session id and
byte counts only, never message bodies.

The settings dialog gains the chat checkbox and the translated labels
for all three languages, and the strict UTF-8 to CP936 helpers move from
McpMachine.cpp to McpInternal so both tool families can share them.

Co-Authored-By: deepseek-flash
2026-10-23 11:19:21 +02:00

14 KiB
Raw Blame History

MCP 远程交谈(P9):AI ↔ 客户端使用者双向对话

1. 目标与边界

让接入服务端 MCP 的大模型与坐在 Windows 客户端电脑前的人进行多轮文字交谈。

  • 仅 Windows 客户端:LNX / MAC / APK 直接返回 -32005,不发命令。
  • 复用既有功能,协议零改动:沿用「远程交谈」(client/ChatManager.cpp + IDD_CHAT), 报文与握手不变。客户端侧只有两处改动:聊天窗口改为 Unicode 对话框(见 §5.8, 解决非 936 ACP 系统上的中文乱码),以及加壳部署下对话框改用内嵌模板兜底(见 §5.7)。
  • 独立开关 McpChat(默认 0):与 McpReadonly 无关;关闭时四个工具不出现在 tools/list,直接调用返回 -32006。
  • 直接弹出客户端窗口:没有确认框;会话建立即代表开始交谈。
  • 验证方式:在虚拟机里运行交互式 Windows 客户端,由人扮演使用者与服务端 AI 对话。

2. 复用协议

方向 报文
服务端主连接 → 客户端 [COMMAND_CHAT=161](1 字节)
客户端新建子连接 先 [TOKEN_CONN_AUTH=246](EnableSubConnAuth),再 [TOKEN_CHAT_START=62]
服务端子连接 → 客户端 [COMMAND_NEXT_CHAT=0](1 字节,通知客户端弹窗)
双向文本 [CP936/GBK 字节][0x00],无 token 前缀
服务端子连接 → 客户端 [COMMAND_CHAT_CLOSE=1] 结束

时序:

chat_open:
  FindMainContext(id) → 校验 Windows → 校验 McpChat
  mainCtx->Send2Client([161])
  客户端建子连接 → [246] 认证 → [62] 就绪
  OnChatSubPacket 绑定 62 → 回 [0] → readyAt = now + 1000ms
  chat_open 等到 started && now >= readyAt 才返回
chat_send:
  subCtx->Send2Client(GBK + NUL) → WaitSendIdle(2000)
客户端使用者输入:
  subCtx → [GBK + NUL] → OnChatSubPacket → inbox → 唤醒 chat_wait
chat_close:
  subCtx->Send2Client([1]) → WaitSendIdle(1000) → CancelIO

未绑定子连接上的 TOKEN_CONN_AUTH(246) 不被聊天钩子消费,仍由 MessageHandle 的鉴权分支处理并回 ConnAuthAck,认证后子连接的 clientID 才可靠(新客户端路径)。 已绑定子连接上首字节为 0xF6 的 GBK 文本不会被误判为鉴权包(绑定连接上所有包都由 聊天钩子消费)。

3. 工具

工具 输入 输出
chat_open id;timeout_ms(默认 15000,2000–60000) {session_id, status:"open", message}
chat_send id, session_id, message {session_id, sent:true, bytes}
chat_wait id, session_id;timeout_ms(默认 30000,0–55000)、max_messages(默认 20,1–50) {session_id, messages:[{id,at,text}], timed_out, closed, dropped}
chat_close id, session_id {session_id, closed:true};主机在线时会话已不存在按幂等处理为成功;主机离线返回 -32002

典型对话循环:

chat_open(id)                    → session_id
chat_send(id, sid, "你好,我是助手")
chat_wait(id, sid, 30000)        → 对方的回复
chat_send(id, sid, "……")
chat_wait(id, sid, 30000)
chat_close(id, sid)

错误码沿用 MCP 约定:

码 含义
-32001 chat_open 等不到客户端建窗(离线、老客户端、非交互桌面)
-32002 主机离线 / 会话不存在或已关闭 / session_id 不匹配
-32003 同设备已有活跃会话;或同一会话已有 chat_send / chat_wait 在飞
-32004 发送失败(Send2Client 返回 FALSE)
-32005 非 Windows 客户端
-32006 McpChat 关闭
-32602 参数、UTF-8、GBK 可表示性或长度错误

4. 编码与长度

  • 出站:UTF-8 → CP936 严格转换(MB_ERR_INVALID_CHARS + WC_NO_BEST_FIT_CHARS + lpUsedDefaultChar)。emoji、ö 等 GBK 不可表示字符 直接返回 -32602,绝不静默变成 ?。
  • 入站:CP936 → UTF-8,按首个 NUL 截断。
  • 出站上限 4000 GBK 字节(客户端输入缓冲 char str[4096],硬上限 4095)。
  • 入站单条上限 8 KiB;inbox 100 条 / 256 KiB,溢出丢最旧并在 chat_wait 的 dropped 中报告。
  • 拒绝空消息、纯空白、\r\n、NUL 及所有 C0 控制字符。

客户端聊天窗口自 P9 起是 Unicode 对话框(见 §5.8):入站 GBK 字节先转 UTF-16 再 显示,出站 UTF-16 严格转 GBK 后发送,因此在德语等非 936 系统上中文不再乱码。协议 本身仍是 GBK,ö/ß/emoji 等 GBK 外字符两端都无法传输(服务端 -32602;客户端 在发送框下方给出提示并不发送)。

5. 客户端怪癖与规避(协议不变)

  1. 首字节 101 ('e') 被丢弃:client/ChatManager.cpp 在任何长度判断之前执行 if (szBuffer[0] == TOKEN_HEARTBEAT) return;。服务端在转换后的 GBK 首字节为 101 时前置一个空格(显示上无感)。若客户端日后修复此行,可移除该规避。
  2. 窗口创建前的文本被丢弃:客户端收到 [0] 后 WaitForDialogOpen 只 Sleep(150),随后才在线程里 CreateDialogParamW;OnReceive 仅在 m_hWnd 非空时显示文本。服务端把 readyAt 设为绑定后 1000ms,chat_open 等满再返回, chat_send 也拒绝早于 readyAt 的发送。
  3. X / 结束交谈只隐藏不关闭:客户端 ShowWindow(SW_HIDE),会话仍活;下一条 服务端文本会 SW_RESTORE 重新弹出。因此隐藏不是关闭信号,结束时必须显式 chat_close。
  4. 一字节控制包:客户端把 len==1 的包当命令,故文本必须带尾部 NUL(至少 2 字节),空消息在工具层拒绝。
  5. 手动「远程交谈」竞争:MCP 会话在飞时用户又点菜单会产生第二个 [62]; 先到者绑定,后到者被吸收并 CancelIO。极窄竞争下用户这次看不到 MFC 窗口, 重试即可。
  6. Session 0 / SYSTEM:COMMAND_CHAT 不像抓屏那样重定向到交互会话;以服务 方式运行的客户端没有交互桌面,chat_open 会超时。VM 验证必须在交互桌面运行。
  7. 加壳部署的对话框兜底:EXE + SC_AES/sRDI 打包时, g_hInstance = GetModuleHandle(NULL) 是外壳进程映像,资源里只有清单、没有 IDD_CHAT;载荷以裸 shellcode 在壳进程内运行,未注册进加载器,Windows 的 FindResource/LoadResource/CreateDialogParamW 取不到对话框 (实测 err=1812 / 59),聊天线程建窗后立即退出。客户端因此保留 CreateDialogParamW 原路径,失败时回退到 CreateDialogIndirectParamW + 内嵌模板(client/ChatDialogTemplate.h,取自载荷 RT_DIALOG 106,240 字节), 图标回退为「直接遍历本模块映像的资源目录取 RT_GROUP_ICON 105 → RT_ICON → CreateIconFromResourceEx」(client/ImageResource.h), 仍失败才用 LoadIcon(NULL, IDI_APPLICATION)。旧客户端无此兜底,加壳部署下 不会弹窗:MDLL 由服务端自动下发新 DLL 即生效,EXE 需重新分发客户端。
  8. Unicode 对话框(非 936 系统乱码修复):客户端是 MBCS 编译,但对话框可以建 成 Unicode 窗口:CreateDialogParamW / CreateDialogIndirectParamW 创建的窗口 IsWindowUnicode 为真,而 DLGTEMPLATEEX 的字符串本来就按 UTF-16 存储。因此 聊天窗口全部改走 W 版 API——GetWindowTextW / SetDlgItemTextW / 日志用 UTF-16 拼接,消息循环用 GetMessageW / IsDialogMessageW / DispatchMessageW (否则 WM_CHAR / WM_IME_* 会先按 ACP 转换)。入站 MultiByteToWideChar(936, …),出站 WideCharToMultiByte(936, WC_NO_BEST_FIT_CHARS, …, &usedDefault);usedDefault 置位即 GBK 不可表示 字符,提示后不发送。IDD_CHAT 字体由点阵 "System" 改为 "MS Shell Dlg" (可缩放,并经 SystemLink 字体链接到中文字体),否则 Unicode 窗口里同样没有 中文字形;前提是系统装有至少一种 CJK 字体(简体中文补充字体或 Microsoft YaHei 等),否则只能显示方框。静态文案按 GetUserDefaultUILanguage() 在中文/英文两套 间切换,Script.rc 因此直接写英文(德语系统看到的是英文而非乱码)。协议与服务端 逻辑不变。

6. 服务端状态机

数据结构(McpServer.h,受 m_ChatMutex 保护):

struct ChatSession {
    std::string sessionId;
    context* mainCtx;        // 主连接(仅身份判断;主连接断开后置空)
    uint64_t mainClientId;
    context* subCtx;         // 已绑定的子连接;关闭后置空
    std::string peer;        // GetPeerName()(老客户端 clientID=0 兜底)
    bool started, closed;
    bool sendBusy, waitBusy; // 同一会话串行发送 / 只允许一个 chat_wait
    steady_clock::time_point readyAt;
    time_t lastActiveAt;
    std::deque<ChatMessage> inbox;   // UTF-8,未投递
    size_t inboxBytes;
    uint64_t nextMsgId;
    int dropped;
};
using ChatSessionPtr = std::shared_ptr<ChatSession>;
  • 会话用 shared_ptr 持有:chat_wait / chat_send 在工作线程复制一份后,即使 map 条目被 chat_close 或断线删除也安全。
  • m_ChatSessions[device]、m_ChatContextToDevice[subCtx]、m_ChatAbsorbedSubCtx 与 m_ChatRoutes(原子计数)供 MessageHandle 快速路径判断;无会话在飞时 OnChatSubPacket 无锁返回,避免音频/屏幕等高频子连接逐包抢锁。
  • 锁序:parent->m_cs(FindMainContext)→ m_ChatMutex。持锁期间不发送、 不 CancelIO、不 WaitSendIdle。
  • 超时/关闭后到达的 [62] 由 5s 墓碑(按 device 与 peer 两个维度)吸收并 CancelIO,避免弹出 MFC 聊天对话框。
  • 空闲回收 600s(任一无 busy 且 lastActiveAt 超时的会话),每个 chat 工具 入口惰性调用。

7. 钩子与生命周期

2015RemoteDlg.cpp:

  1. MessageHandle:MCP 终端块之后、P8 OnManagedSubPacket 之前调用 OnChatSubPacket。聊天文本是裸 GBK,首字节可能是 61/255,若先交给 P8 会被 误绑定。
  2. OfflineProc:P8 清理之后、RemoveFromHostList 之前调用 OnChatContextClosed(连接池会复用 context*,晚清理会误路由新连接)。 子连接断开 → 会话 closed 并唤醒 chat_wait;主连接断开 → 关闭其所有会话并 锁外 CancelIO 子连接。
  3. 启动:SetChatEnabled(THIS_CFG.GetInt("settings", "McpChat", 0) != 0)。

审计:AuditChatAction 与 AuditManageAction 同落地方式(WM_SHOWERRORMSG), 标题 _TR("MCP远程交谈");只记录 session=... 与 bytes=N,绝不记录消息正文。 chat_wait 不审计,避免刷屏。

8. 配置

INI key 默认 说明
McpChat 0 远程交谈工具族开关;改动需重启服务端

「扩展 → MCP设置」新增复选框「启用远程交谈(AI 与用户对话)」。

9. 验证矩阵

准备:McpEnabled=1、McpChat=1 后重启服务端;MCP 客户端重连使 tools/list 刷新;VM 中交互运行 Windows 客户端。

# 操作 期望
1 tools/list(开关开 / 关) 四个 chat 工具出现 / 消失;关闭时直调 -32006
2 chat_open(id) VM 约 1s 内弹出「远程交谈」;返回 32 位 hex session_id
3 chat_send 中文;用户回中文;chat_wait VM 显示「对方: …」;返回 UTF-8 原文
4 连续多轮、用户连发两条 下一轮批量返回,顺序正确,dropped=0
5 发送 "echo test"(首字节 'e') 正常显示(前置空格规避生效)
6 open 后立刻 send,重复 10 次 第一条不丢(1000ms 建窗等待)
7 4000 / 4001 字节、emoji 边界内成功;超长与 emoji 返回 -32602
8 无回复时 chat_wait 到点 timed_out:true,会话仍活
9 用户点 X 隐藏后再 send 窗口恢复,会话仍活
10 chat_close 窗口关闭;旧 id 发送 -32002;立即重新 open 成功
11 chat_wait 阻塞时杀掉客户端 返回 closed:true;服务端稳定;重连后可重新 open
12 Linux / macOS / Android 主机 -32005,不发命令
13 同设备第二次 chat_open -32003
14 无 MCP 会话时手动「远程交谈」 MFC 聊天窗口照常打开(回归)
15 服务端审计日志 有 open/send/close 的 session id 与字节数,无消息正文
16 现有 MCP 工具 + 手动主机/连接/终端对话框 行为不变
17 chat_wait timeout_ms=55000 在常见 60s 工具超时前返回
18 加壳(SC_AES/sRDI)EXE 客户端 chat_open 仍弹出「远程交谈」且标题栏图标正常(内嵌模板 + 自模块映像取图标);客户端日志有 CreateDialogParamW(1812) 失败,改用内嵌对话框模板
19 非 936 系统(如德语 Windows)chat_open + 中文 chat_send 标题、按钮为英文(系统 UI 非中文),中文消息正常显示而非乱码;中文系统为中文按钮
20 德语系统客户端发送 Grüße / emoji 客户端不发送并在日志区提示 GBK 不支持;服务端若直接 chat_send 同类字符返回 -32602
21 未安装任何 CJK 字体的系统 中文显示为方框(字体缺失,非编码问题);安装中文补充字体后恢复

10. 已知限制

  • 无投递回执:sent:true 只表示已写入子连接并 WaitSendIdle 成功,不保证 使用者已看见。
  • HTTP 工作线程:chat_wait 最长 55s 会占用一个 httplib 工作线程;不要在 同一 MCP 连接上并行大量长等待。
  • 连接池复用窄竞争:发送时捕获的 subCtx 在极端情况下可能已断开并被池复用; 与现有终端/屏幕实现同一取舍,靠 closed 检查与 WaitSendIdle 缩短窗口。
  • 极慢 VM:若首条消息仍丢,可把 kChatOpenSettleMs 调到 1500–2000ms,代价是 chat_open 变慢。
  • 仍是 GBK 通道:Unicode 对话框解决了「显示」,但线路上仍是 CP936,德语变音 字母(ö/ä/ß)、emoji 等无法传输。要根治需引入会话级编码协商(如 TOKEN_CHAT_START 后追加能力位,双方改用 UTF-8),属未来工作;服务端与客户端 必须同时升级,且旧客户端不能误解新报文。
  • 字体依赖:中文显示依赖系统已安装的 CJK 字体(字体链接到 "MS Shell Dlg" 的后备字体);仅此一点无法由程序保证,缺少字体时显示为方框。