# MCP 远程命令与持久终端 — 设计与实现 > 本文档覆盖 MCP 侧两类**命令执行**能力: > 1. **一次性命令 `exec_command`**(P3a)—— 每次新建一个 shell、跑一条命令、读完即关。 > 2. **持久终端 `terminal_open` / `terminal_exec` / `terminal_close`**(P4)—— 同一主机**一个** shell 会话,跨命令保持 cwd 与环境变量。 > > 阅读顺序建议:先读 `docs/Mcp_Design.md`(Phase 1:HTTP 协议、token 校验、配置、`list_online_hosts`)与 `docs/Mcp_Phase2_Design.md`(模式 A/A′/B、工具路线图、P2a–P3a 概要),再读本文档的**实现级细节**。本文档是后续人员 / 大模型接手与排查问题的主参考。 --- ## 1. 术语与总览 | 术语 | 含义 | |---|---| | **一次性命令** | `exec_command`:无状态,每次独立 shell,命令结束即 `CancelIO` 关闭子链接。 | | **持久终端** | `terminal_open`→`terminal_exec`×N→`terminal_close`:有状态,cwd/env 跨命令保持。 | | **哨兵(sentinel)** | 包装在命令尾部的一段随机标记,用于在输出流里定位「命令真实退出码」并截断输出。 | | **子链接(sub-connection / `context*`)** | 客户端为 shell 建立的独立网络连接,`context` 是其服务端句柄。 | | **主连接(`FindMainContext`)** | 客户端与控制端的常驻长连接,用于下发 `COMMAND_*`。 | | **isPty** | `true`=ConPTY(UTF-8,`cp=CP_UTF8`);`false`=老 cmd 管道(GBK,`cp=936`)。 | 两类能力**共享同一套底层机制**(终端链路、编码、哨兵、状态机 map、审计),区别只在**生命周期**(一次性 vs 保持)与**安全门**(白名单 vs 无白名单 + 独立开关)。这也是为什么它们会互相排他(见 §6.3)。 --- ## 2. 共享基础设施 ### 2.1 客户端终端链路(两种能力通用) 无论一次性还是持久,shell 的建立过程完全相同: ``` 服务端 客户端 │ COMMAND_SHELL(主连接) ──► 客户端创建 shell 子连接 │ ◄── TOKEN_SHELL_START 或 TOKEN_TERMINAL_START(子连接) │ ↓ MessageHandle 顶部判定 IsTermPending → 交给 RegisterTerminalContext 接管 │ COMMAND_NEXT(+ 若 PTY 先发 CMD_TERMINAL_RESIZE 80x24)──► 客户端启动 shell 输出回流 │ ◄── 持续 shell 输出(TOKEN_TERMINAL_DATA 之类,经 IsTerminalContext 路由到 OnTerminalData) │ ◄── TOKEN_TERMINAL_CLOSE(shell 进程退出)→ OnTerminalClosed ``` 关键点: - **`COMMAND_NEXT` 不能漏发**——客户端读线程靠它才启动输出回流,漏发会导致 shell 在跑但输出永不送回(`RegisterTerminalContext` 内的注释明确强调)。 - **PTY 需先告知初始尺寸 80×24**,否则 TUI 尺寸错乱。 - `TOKEN_SHELL_START` 与 `TOKEN_TERMINAL_START` 二选一:老 `ShellManager`(cmd 管道)回前者,ConPTY 回后者;服务端在 `MessageHandle` 里据此判定 `isPty`。 ### 2.2 编码 | isPty | 底层 | 服务端 cp | |---|---|---| | `true` | ConPTY | `CP_UTF8` | | `false` | 老 cmd 管道 | `936`(GBK) | - **发送方向**:命令/哨兵行先 UTF-8 组好,再 `ToAnsi(line, cp)` 转成客户端编码,追加 `"\r\n"`。 - **接收方向**:原始字节 `ToUtf8(raw, cp)` 转回 UTF-8。 - 哨兵是纯 ASCII,两种编码下字节一致,编码无关。 ### 2.3 哨兵机制 命令被包装成一行复合命令: ``` [@echo off & ] 2>&1 && echo __MCP_DONE___0 || echo __MCP_DONE___1 ``` - `@echo off & ` **仅 ConPTY 需要**(抑制 ConPTY 把输入整行回显)。老 `ShellManager` 已自行跳过回显,加 `@echo off` 反而破坏它的回显跳过逻辑。 - `&&` / `||` 控制操作符取**命令真实退出码**(`%errorlevel%` 在复合句中解析期展开、已过期,故不能用 `%errorlevel%`)。 - `` 为 8 位随机 hex(`GenerateRandomToken().substr(0,8)`),保证命令真实输出不可能恰好包含完整哨兵串。 - `2>&1` 让 stderr 也走哨兵链,stdout/stderr 都能被捕获且退出码正确。 **`FindSentinel`(检测)** 用 `rfind` **从后往前**找,且要求哨兵在**行首**(`p==0` 或前一个字节是 `\n`),后跟 `0` 或 `1`: ```cpp size_t from = npos; while (true) { size_t p = s.rfind(marker, from); bool lineStart = (p == 0) || (s[p-1] == '\n'); if (lineStart && s[p+marker.size()] 是 '0' 或 '1') { exitCode=...; pos=p; return true; } from = p - 1; } ``` 为什么要「从后往前 + 行首」:ConPTY 会把**整条命令行回显**进输出流,回显里的哨兵字样嵌在 `&& echo ... || echo ...` 中间、前面是空格(非行首)。若用 `rfind` 直取,回显包先于真实输出到达时会命中回显里的 `__1` 提前结束、截断真实输出。行首校验能排除回显行。老 `ShellManager` 无回显,输出里只有真实哨兵(仍在行首),逻辑一致。 ### 2.4 输出清洗管线 从原始字节到返回 `stdout` 字符串,固定五步(`CleanTerminalOutput`,一次性命令为内联等价逻辑): ``` raw 字节 → ToUtf8(raw, cp) 按 2.2 编码解码 → StripAnsi(...) 剥 CSI/OSC/单字节 ESC,去彩色码 → CRLF → LF "\r\n" 归一为 "\n" → StripEchoedCommand(stdout, sentLine) 剔 ConPTY 输入行回显(见下) → TrimRight(...) 去尾部空白 ``` **`StripEchoedCommand`(回显剔除)**:ConPTY 的 `ENABLE_ECHO_INPUT` 会在 `@echo off` 生效**之前**把整条输入命令行回显进输出流。修复方法是在**服务端**把「刚发送的、含唯一 nonce 的完整命令行」(`SendTerminalCommandLine` 的返回值)从输出里整行 `find` + 剔除: ```cpp static std::string StripEchoedCommand(const std::string& s, const std::string& echoedLine) { if (echoedLine.empty() || s.empty()) return s; size_t p = s.find(echoedLine); if (p == npos) return s; size_t q = p + echoedLine.size(); if (q < s.size() && s[q] == '\n') ++q; // CRLF 已归一为 \n return s.substr(0, p) + s.substr(q); } ``` - 因 `echoedLine` 含随机 nonce,命令真实输出不可能恰好相同 → 不会误删合法输出。 - 老 `ShellManager` 无回显,`find` 不到该行 → 无操作,安全。 ### 2.5 状态机与互斥(两类能力共用) ```cpp struct TermSession { bool started; // false=已发 COMMAND_SHELL 待 START;true=已接管 context* subCtx; // shell 子连接上下文 bool isPty; // ConPTY(UTF-8) / cmd 管道(GBK) std::string command; // 原始 UTF-8 命令(审计用) std::string nonce; // 哨兵随机串 std::vector data; // 收集的原始 shell 输出 size_t sentPos; // 哨兵在 data 中的位置 int exitCode; // 0/1/-1 bool done; // 哨兵命中 bool closed; // TOKEN_TERMINAL_CLOSE(进程退出) // ── 以下为持久终端新增 ── std::string sessionId; // 持久会话 token(一次性 exec 为空) bool persistent; // 是否持久会话 bool busy; // 一条命令在飞(terminal_exec 复位 → WaitTermCommand 返回) time_t lastActiveAt; // idle 回收用(秒) }; std::mutex m_TermMutex; // 保护下面两个 map std::condition_variable m_TermCv; std::map m_TermSessions; // device_id → 会话 std::map m_TermContextToDevice; // subCtx → device_id(顶部路由) ``` - `m_TermContextToDevice` 是**反向索引**:`MessageHandle` 顶部用 `IsTerminalContext(subCtx)` 判定「这个子链接的输出是否该路由到 MCP 的 `OnTerminalData`,而不是打开 MFC 终端对话框」。 - `httplib` 默认多线程处理请求 → **多个 `tools/call` 会并发**。所有会话状态都靠 `m_TermMutex` + `busy` 串行化,**不要假定请求串行**。 --- ## 3. 一次性命令 `exec_command` ### 3.1 安全门(顺序) ``` ParseHostIdArg → -32602 参数非法 FindMainContext → -32002 主机不存在/离线 clientType==LNX/MAC → -32005 仅 Windows command 为空 → -32602 mcp.IsReadonly() → -32006 只读模式默认开,需 McpReadonly=0 含 &|<>^ 五元字符 → -32008 防注入绕过白名单 含控制字符(\r\n 等 <0x20) → -32008 防换行拆分绕过白名单 白名单前缀校验 → -32007 不在白名单 ``` - **白名单**:`IsCommandAllowed` 做「trim + ASCII 小写后前缀匹配」,且前缀后须为空/空格/制表符(防 `dirx` 误配 `dir`)。配置为空时回落到内置 `kDefaultCmdWhitelist`: ``` dir,type,cd,chdir,ver,hostname,whoami,where,tasklist,systeminfo,ipconfig,netstat, path,set,find,findstr,reg query,sc query, ping,tracert,nslookup,getmac,driverquery,query,gpresult, arp -a,route print,schtasks /query ``` - **元字符门 `&|<>^`** 是**安全门**(拒绝 shell 注入/命令拼接),比持久终端的 `&|` 更严。 - **控制字符门**:同时拒绝所有 `<0x20` 的控制字符(`\r`/`\n` 等)。否则 `dir \n rd /s /q ...` 这种带内嵌换行的命令会通过「前缀 `dir` + 空格」的白名单校验,却在 `cmd.exe` 里被换行拆成两条独立命令、执行任意后续行——彻底绕过只读白名单。这是 2026-08-24 独立审查发现并修复的**安全漏洞**。 ### 3.2 流程 ``` BeginTermPending(devId, command, nonce) → false 则 -32003(该 host 已有终端会话) ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004 WaitTerminalReady(devId, subCtx, isPty, timeoutMs) → 超时 -32001(内部已自清理) 组哨兵行 Send2Client (见 §2.3) WaitTerminalDone(devId, raw, exitCode, closed, timeoutMs) → 超时 -32001(内部已自清理 + 调用方 CancelIO) subCtx->CancelIO() ← 一次性:读完即关子链接,结束 shell 清洗输出(§2.4) 审计:PostMessageA(WM_SHOWERRORMSG, ..., "MCP命令执行") 返回 { stdout, exit_code } ``` ### 3.3 `WaitTerminalDone` 三态(一次性专用) - **哨兵命中(done)**:`out` = `data[0..sentPos)`(截断到哨兵前),`exitCode=0/1`,**擦除会话+路由**,返回 true。 - **进程退出(closed)**:`out` = 全部 `data`(无哨兵),`exitCode=-1`,擦除会话+路由,返回 true。 - **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。 --- ## 4. 持久终端 `terminal_open` / `terminal_exec` / `terminal_close` ### 4.1 工具契约 | 工具 | 入参(required) | 出参 | |---|---|---| | `terminal_open` | `id`(+`timeout_ms`) | `session_id`(32hex)、`is_pty`(bool) | | `terminal_exec` | `id`、`session_id`、`command`(+`timeout_ms`) | `stdout`、`exit_code` | | `terminal_close` | `id`、`session_id` | `closed`(bool,恒 true) | `session_id` 由服务端 `GenerateRandomToken()` 生成(32 hex / 128 bit)。 ### 4.2 共享前置 `ResolveTerminalSessionHost` `terminal_open` / `terminal_exec` 复用(`terminal_close` 为幂等内联版,见 §4.5): ``` IsTerminalEnabled() && !IsReadonly() → -32006(需 McpTerminal=1 且 McpReadonly=0) ParseHostIdArg → -32602 FindMainContext → -32002 主机不存在/离线 clientType==LNX/MAC → -32005 仅 Windows ``` ### 4.3 `terminal_open` ``` SweepIdleTerminals(300) ← 先回收闲置会话 ResolveTerminalSessionHost sessionId = GenerateRandomToken() BeginTermOpen(devId, sessionId) → false 则 -32003(单设备单终端) ctx->Send2Client(COMMAND_SHELL) → 失败 ClearTermPending + -32004 WaitTerminalReady(...) → 超时 -32001(自清理) 审计 "term-open" 返回 { session_id, is_pty } ``` **关键**:`WaitTerminalReady` 成功后**不擦除会话**(与一次性相反)——会话要留给后续 `terminal_exec`。此时 `started=true`、`busy=false`,shell 空闲运行。 ### 4.4 `terminal_exec` ``` SweepIdleTerminals(300) ResolveTerminalSessionHost session_id 为空 → -32602 command 为空 → -32602 command 含 & 或 | → -32008(正确性门,见 §4.6) nonce = 随机 8 hex BeginTermCommand(devId, sessionId, command, nonce, subCtx, isPty) → false 则 -32002(不存在/不匹配/未就绪/忙碌/已关) sentLine = SendTerminalCommandLine(subCtx, isPty, command, nonce) WaitTermCommand(devId, raw, exitCode, closed, timeoutMs) → 超时:subCtx->CancelIO() + -32001 → closed:subCtx->CancelIO()(会话已被清理,仅对称无害) stdoutStr = CleanTerminalOutput(raw, cp, sentLine) 审计 "term-exec [sessionId]: command" 返回 { stdout, exit_code } ``` **`WaitTermCommand` 三态(持久专用,与一次性不同)**: - **哨兵命中(done)**:`busy=false`、刷新 `lastActiveAt`、`out` 截到 `sentPos`、**保留会话**,返回 true。← 这是「持久」的核心:命令完成后 shell 不关。 - **进程退出(closed)**:`out`=全部 `data`、`exitCode=-1`、**擦除会话+路由**,返回 true。 - **超时**:擦除会话+路由,返回 false(调用方 `CancelIO`)。 ### 4.5 `terminal_close` ``` SweepIdleTerminals(300) IsTerminalEnabled() && !IsReadonly() → -32006 ParseHostIdArg → -32602 session_id 为空 → -32602 CloseTermSession(devId, sessionId) r==2(sessionId 不匹配) → -32002(防拼错静默泄漏真会话) 审计 "term-close" 返回 { closed: true } ``` `CloseTermSession` 返回值语义:**0**=已关闭;**1**=会话不存在(幂等,仍返回 `{closed:true}`);**2**=sessionId 不匹配(报错)。 ### 4.6 正确性门 `&|`(非安全门) `terminal_exec` **拒绝含 `&` 或 `|` 的命令**(`find_first_of("&|")`,含 `&&`/`||`/`|`),报 `-32008`。原因:哨兵包装 `cmd 2>&1 && echo ... || echo ...` 假定命令是「扁平」的,命令内含 `&`/`|` 会与包装的控制操作符合流、导致退出码失真/输出归属不清。 - 这是**正确性限制,不是白名单**——持久终端本就不设白名单。 - 同样拒绝 `<0x20` 的控制字符(`\r`/`\n`):换行会把命令拆成多行、只有末行被哨兵包装,导致退出码与输出归属失真。 - `> < ^`(重定向 / 转义)**允许**:不破坏哨兵链。AI 把链式/管道命令拆成多条 `terminal_exec` 调用即可(cwd/env 保持,等价)。 对比:一次性 `exec_command` 因有白名单,其元字符门更严(`&|<>^` 五元字符全拒,§3.1)。 --- ## 5. 状态机方法明细 ### 5.1 一次性命令专用 | 方法 | 作用 | |---|---| | `BeginTermPending(devId, cmd, nonce)` | 锁内登记会话;已有会话则 false(单设备单终端)。 | | `WaitTerminalReady(devId, subCtx, isPty, timeout)` | 等 `started`;超时**擦除**并返回 false。成功输出 `subCtx`/`isPty`,**不擦**。 | | `WaitTerminalDone(...)` | 等 `done||closed`;三态见 §3.3。 | | `ClearTermPending(devId)` | 发送失败等提前退出路径的清理。 | | `IsTermPending(devId)` | `MessageHandle` 判定是否有待接管的终端会话。 | | `RegisterTerminalContext(devId, subCtx, isPty)` | 子连接就绪接管:置 `started/subCtx/isPty`、登记反向索引、发 `COMMAND_NEXT`(+PTY resize)、`notify_all`。 | | `IsTerminalContext(subCtx)` | `MessageHandle` 顶部路由判定。 | | `OnTerminalData(subCtx, data, len)` | 追加输出、`FindSentinel` 命中置 `done` + `notify`;`done||closed` 时忽略迟到数据。 | | `OnTerminalClosed(subCtx)` | 置 `closed`;空闲持久会话直接擦;`notify_all` 唤醒 busy 等待线程。 | ### 5.2 持久终端专用 | 方法 | 作用 | |---|---| | `BeginTermOpen(devId, sessionId)` | 锁内建持久会话(`persistent=true`、`lastActiveAt=now`);已有会话 false。 | | `BeginTermCommand(devId, sid, cmd, nonce, subCtx, isPty)` | 锁内校验「persistent && sid 匹配 && started && !busy && !closed」→ 置 `busy=true`、**原子复位** `data/sentPos/exitCode/done/closed`、更新 `command/nonce/lastActiveAt`。 | | `WaitTermCommand(...)` | 等 `done||closed`;三态见 §4.4。 | | `CloseTermSession(devId, sid)` | 不存在→1;sid 不匹配→2;`busy` 时仅置 `closed`+notify(交等待线程清理,防双重擦除);非 busy 直接擦+锁外 `CancelIO`。 | | `SweepIdleTerminals(idleTimeoutSec)` | 回收 `persistent && !busy && difftime(now,lastActiveAt)>idle` 的会话;收集后锁外 `CancelIO`。 | ### 5.3 `OnTerminalClosed` 的泄漏修复 `OnTerminalClosed` 有**两个入口**: 1. `MessageHandle` 的 `TOKEN_TERMINAL_CLOSE` 分支(客户端 shell 优雅退出时发 token)。 2. `OfflineProc`(`2015RemoteDlg.cpp`)——连接**骤然断开**(网络断/客户端崩溃,无 token)时,与 `WebService().OnTerminalClosed` 并列调用 `McpServer().OnTerminalClosed`。此入口是 2026-08-24 独立审查发现缺失并补齐的:否则骤断的持久终端会话会在 `m_TermSessions`/`m_TermContextToDevice` 里留悬空 `subCtx`,连接池复用地地址后会误路由新连接、甚至 `CancelIO` 掉无关连接。 shell 进程退出时,若是一个**空闲持久会话**(`persistent && !busy && started`),没有等待线程会清理它,会泄漏死 `subCtx`。故: ```cpp s.closed = true; if (s.persistent && !s.busy && s.started) { m_TermContextToDevice.erase(subCtx); // 空闲持久会话:直接擦(无需 CancelIO,已死) m_TermSessions.erase(sit); } m_TermCv.notify_all(); // busy 场景唤醒等待线程由其清理;一次性 exec 路径不变 ``` --- ## 6. 并发模型与不变量 ### 6.1 关键不变量 - **`CancelIO` 一律在 `m_TermMutex` 之外**(所有路径:`BuildExecCommand`、`BuildTerminalExec`、`CloseTermSession`、`SweepIdleTerminals`)。这是硬约束——`CancelIO` 可能阻塞/回调,锁内调用会死锁。 - **`wait_for` 期间不持有会被他人擦除的迭代器**:等待线程(`WaitTermCommand`)持 `it`,但**只有它自己**会在超时/closed 路径擦除该会话;`terminal_close`/`sweep` 因 `busy` 标志而**不会**擦除「正在等待的命令」的会话。这就是 `busy` 标志的核心作用。 - **单设备单终端**:`terminal_*` 与 `exec_command` 因**共享 `m_TermSessions`** 而互斥(双向 `-32003`)。`BeginTermPending` 与 `BeginTermOpen` 都做「已有会话则 false」。 ### 6.2 `busy` 标志生命周期 ``` terminal_exec: BeginTermCommand 置 busy=true Send2Client WaitTermCommand 返回时: done 路径 → busy=false(保留会话) closed 路径 → 会话被擦除 超时路径 → 会话被擦除 terminal_close(会话忙时)→ 仅置 closed=true + notify,不擦,交等待线程清理 ``` ### 6.3 互斥矩阵 | | `exec_command` | `terminal_*` | `list_files`/`get_screenshot` 等 | |---|---|---|---| | `exec_command` | 互斥(-32003) | 互斥(-32003) | 无关(各用 `m_Pending`) | | `terminal_*` | 互斥 | 单设备单终端 | 无关 | > Web 终端与 MCP 终端各用独立 map,理论上可同设备并存(既有现象,非本次范围)。 ### 6.4 idle 回收语义 `SweepIdleTerminals(300)`:`lastActiveAt` 在 `BeginTermOpen` / `BeginTermCommand` **(命令开始时)**更新,**命令完成后不刷新**。因此「两次命令间隔 < 300s」的活跃会话安全;「一次长命令 + 长时间无新命令」的会话会在最后一次命令开始 300s 后被回收。回收是非惰性的——每次 `terminal_*` 入口先 sweep,所以空闲会话不会在无请求时被后台主动关闭(但也不占后台定时器资源)。用 `difftime` 比较防时钟回拨下溢。 --- ## 7. 安全模型 | 维度 | `exec_command` | 持久终端 | |---|---|---| | 开关 | `McpReadonly=0` | `McpTerminal=1` **且** `McpReadonly=0`(默认 `McpTerminal=0`) | | 白名单 | 有(内置/自定义前缀白名单) | **无**(完整 shell,可写命令、重定向、改环境) | | 元字符门 | `&|<>^` + 控制字符全拒(安全门) | `&|` + 换行拒(正确性门) | | 审计 | `WM_SHOWERRORMSG` 标题「MCP命令执行」 | 标题「MCP持久终端」(open/exec/close 各一条) | | 生命周期 | 命令结束即关 | 会话保持,闲置 300s 回收,`terminal_close` 显式关 | | 会话隔离 | 单设备单终端 | 单设备单终端 + `session_id` 校验防串用 | 审计实现:`PostMessageA(WM_SHOWERRORMSG, new CString(...), new CString(_TR("...标题...")))` → 进入服务端 `m_MessageLog`,`get_audit_log` 可查。审计不可关闭。 --- ## 8. 配置项 | 键 | 默认 | 说明 | |---|---|---| | `McpEnabled` | 0 | MCP 总开关 | | `McpPort` | 6544 | 监听端口 | | `McpBind` | 127.0.0.1 | 绑定地址(默认仅回环) | | `McpToken` | (空→运行时兜底) | Bearer token | | `McpReadonly` | 1 | 只读模式(禁 `exec_command` 与持久终端) | | `McpCmdWhitelist` | (空→内置白名单) | `exec_command` 白名单,逗号分隔 | | `McpTerminal` | 0 | 持久终端开关(要求 `McpReadonly=0`) | **存储位置**:Release 版存**注册表** `HKCU\Software\YAMA\settings`(**不是** `settings.ini`;`settings.ini` 仅 Debug 版读取)。改动需**重启程序**生效。 **启动读取**(`2015RemoteDlg.cpp`): ```cpp McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0); McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", "")); McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0); ``` **工具可见性**:`terminal_*` 三个工具在 `tools/list` 里**仅在** `IsTerminalEnabled() && !IsReadonly()` 时出现(门控在 `BuildToolsListResult`)。故默认配置下 `tools/list` 看不到它们。 --- ## 9. 错误码 | 码 | 含义 | 触发点 | |---|---|---| | `-32001` | 超时 | `WaitTerminalReady`/`WaitTermCommand`/`WaitPending` 等超时 | | `-32002` | 主机/会话不存在 | `FindMainContext` 失败、`session_id` 不匹配/未就绪/已关 | | `-32003` | 设备忙 | 单设备单终端冲突、同 host 已有挂起请求 | | `-32004` | 发送失败 | `Send2Client` 失败 | | `-32005` | 非 Windows | LNX/MAC 客户端 | | `-32006` | 已禁用/只读 | `IsReadonly()` 或 `!IsTerminalEnabled()` | | `-32007` | 白名单拒绝 | `IsCommandAllowed` false | | `-32008` | 含禁用字符 | `exec_command` 的 `&|<>^`;`terminal_exec` 的 `&|` | | `-32602` | 参数非法 | `ParseHostIdArg` 失败、缺 required 参数 | | `-32700` | JSON 解析错误 | 请求体非合法 JSON-RPC | --- ## 10. 排查指南(供后续人员/大模型) ### 10.1 `exec_command` 返回 `-32001` 超时 - 先看是不是**目标机环境问题**而非代码 bug:`nslookup`/`where` 在部分机器会因 DNS/文件系统异常稳定超时(见项目记忆)。裸 `nslookup ` 卡默认重试 20~40s > MCP 超时;`where cmd.exe` 在特定机器卡 PATH 解析。 - 服务端行为正确:超时返回 `-32001` 并清理终端会话,不挂起/崩溃。 ### 10.2 `terminal_*` 在 `tools/list` 里看不到 - 检查 `McpTerminal=1` **且** `McpReadonly=0`。任一不满足,工具不出现且调用返回 `-32006`。 - 改配置后需**重启程序**(注册表配置启动时读)。 ### 10.3 `terminal_exec` 返回 `-32002` - 会话已被回收(闲置 >300s)、shell 已退出、或 `session_id` 拼错。重新 `terminal_open`。 - `terminal_close` 的 `-32002` 特指 **session_id 不匹配**(防串用)。 ### 10.4 `-32003` 设备忙 - 单设备单终端:同 host 已有终端会话(持久或一次性)在飞。等它结束或 `terminal_close`。 - 紧接 `terminal_close`(会话 busy 时)后立刻 `terminal_open` 可能偶发 `-32003`:busy 会话的关闭是「标记 closed + 交等待线程清理」,清理完成前有极短窗口。重试一次即可,非死锁。 ### 10.5 stdout 里出现命令本身 / 哨兵 - 命令本身回显 = ConPTY 输入回显,已由 `StripEchoedCommand` 服务端剔除(§2.4)。若仍出现,检查是否该行因编码(非 PTY 老管道的 GBK)与 `echoedLine` 字节不一致而 `find` 不到——老管道本应无回显。 - 哨兵 `__MCP_DONE_...` 出现 = 哨兵截断/清洗异常,通常意味着命令含 `&|` 破坏了控制操作符链(持久终端会提前 `-32008` 拒绝,一次性会 `-32008` 拒绝五元字符)。 ### 10.6 中文/乱码 - 输出乱码:检查 `isPty` 与 `cp` 是否匹配(PTY=UTF-8,老管道=GBK)。 - **`get_audit_log` 中文乱码**:已知问题(GBK 写入 UTF-8 JSON),影响**所有**审计条目,非终端特有,暂未修复。 ### 10.7 会话泄漏 / 子链接不关 - 三个擦除点:`WaitTermCommand`(closed/超时)、`CloseTermSession`、`SweepIdleTerminals`;以及 `OnTerminalClosed` 对**空闲持久会话**的直接擦除(§5.3)。排查时确认 shell 退出路径 `OnTerminalClosed` 是否命中、`COMMAND_NEXT` 是否漏发导致输出永不回流。 --- ## 11. 关键源文件索引 | 文件 | 内容 | |---|---| | `server/2015Remote/McpServer.h` | `TermSession` 结构、`BeginTermOpen/Command/WaitTermCommand/CloseTermSession/SweepIdleTerminals` 声明、`m_terminalEnabled` | | `server/2015Remote/McpServer.cpp` | `BuildExecCommand`(~1946)、`BuildTerminalOpen/Exec/Close`(~2345)、状态机方法(~2660)、`FindSentinel`(~2569)、`StripEchoedCommand`(~1935)、`SendTerminalCommandLine`(~2192)、`CleanTerminalOutput`(~2206)、`ResolveTerminalSessionHost`(~2160) | | `server/2015Remote/McpSettingsDlg.h/.cpp` | `IDC_MCP_TERMINAL` 复选框、回填/落盘 `McpTerminal` | | `server/2015Remote/2015RemoteDlg.cpp` | 启动读 `McpTerminal` → `SetTerminalEnabled` | > 构建由 VS2019(MSBuild v143)完成;项目工具集 v142,无法在外部环境构建。