diff --git a/docs/Mcp_Phase2_Design.md b/docs/Mcp_Phase2_Design.md new file mode 100644 index 0000000..62f7b16 --- /dev/null +++ b/docs/Mcp_Phase2_Design.md @@ -0,0 +1,358 @@ +# YAMA MCP 功能开发技术书(Phase 2 及后续) + +> **状态**:设计定稿(已按两轮评审修订);P2a 已实现,待 build 验证。 +> **读者**:MCP 后续功能的研发/评审人员。 +> **关联文档**:[Mcp_Design.md](./Mcp_Design.md)(Phase 1 的协议、架构、配置与菜单设计)。 +> **核心目标**:让 MCP 从「单个只读工具」平滑演进为「分阶段、可回滚、影响面可控」的工具集,**不一次性大改现有功能**。 + +--- + +## 1. 背景与目标 + +Phase 1 已跑通 MCP 最小闭环:`httplib` + JSON-RPC 2.0,单工具 `list_online_hosts`,静态 Bearer token,经「扩展 → MCP设置」对话框开启。目标是把 YAMA 服务端已有的主机管理能力(进程、窗口、终端、截图、文件等)**逐步**暴露为 MCP 工具,供 Claude Code 等 AI 助手以工具调用方式使用。 + +本技术书解决三个问题: + +1. **做什么**——工具路线图与优先级(只读 → 安全写 → 会话)。 +2. **怎么做**——复用现有协议,无头驱动、不弹对话框;**区分「主连接 RPC」与「子链接流式」两种模式**。 +3. **怎么稳**——每个阶段独立可交付、可回滚,影响面小、可验收。 + +--- + +## 2. 现状回顾(Phase 1 已交付) + +| 模块 | 文件 | 职责 | +|------|------|------| +| HTTP/JSON-RPC 分发 | `McpServer.h` / `McpServer.cpp` | `POST /mcp`;token 校验;`initialize`/`ping`/`tools/list`/`tools/call` | +| 工具序列化复用 | `HostJson.h` / `HostJson.cpp` | `BuildHostJson(ctx, clientMap)`,单主机 JSON(与 WebService 共用) | +| 配置对话框 | `McpSettingsDlg.h` / `.cpp` | 运行时构造对话框:enable/port/bind/token | +| 生命周期接入 | `2015RemoteDlg.cpp:2136-2162`(启动)、`3900-3906`(停止) | 随主对话框启停 | +| FRP 暴露 | `2015RemoteDlg.cpp:3269-3304` | MCP 端口仅在「启用 + 绑 `0.0.0.0`」时纳入 FRP | + +关键结论:**MCP 未引入任何新第三方库**——`httplib`(HTTP)与 `jsoncpp`(JSON)均为项目既有依赖。 + +当前工具实现只有一个 dispatch 分支(`McpServer.cpp` 的 `BuildToolsCall`),新增工具的边际成本很低:**注册 `outputSchema` + 加一个 `tools/call` 分支**。 + +--- + +## 3. 设计原则(贯穿所有后续开发) + +1. **只读优先,写操作单独评审**:延续 `Mcp_Design.md §1.3`。只读工具直接做;任何「改变被控端状态」的工具(命令执行、杀进程、传文件、控制)必须经安全评审 + 独立授权。 +2. **复用现有协议,不新造命令**:一切数据获取/操控都走 `common/commands.h` 里既有的 `COMMAND_*`(服务端→客户端)与 `TOKEN_*`(客户端→服务端),不新增自定义命令。 +3. **无头接管复用 Web 终端模式**:对话框只是 UI 外壳;MCP 复用 `WebService` 的挂起标记/接管模式,把数据接到 MCP 侧,**不弹框**。 +4. **每阶段独立可交付、可回滚**:一个阶段 = 一批工具 + 对应验收;出错可单独 revert,不连坐。 +5. **超时与清理是硬约束**:MCP 是 request/response,一次 `tools/call` 不能无限阻塞等数据;必须有超时、迟到回收、用完即关。 +6. **输出 schema 先行**:每个工具先在 `tools/list` 里声明完整 `inputSchema`/`outputSchema`,AI 客户端据此决定何时调用、如何解析。 + +--- + +## 4. 核心机制:无头驱动(双模式) + +> **本节是本技术书的关键**。此前设计把「进程/窗口/截图」误当作「子链接」,评审后纠正为:这些是**主连接 request/response**;真正走子链接的只有终端、完整屏幕、文件传输等大流量/持续流能力。两者实现机制不同,必须分开建模。 + +### 4.1 连接模型:主连接 vs 子链接 + +`common/commands.h` 定义了完整协议,分两层: + +- **主连接**:客户端上线后与主控保持的长连接。承载心跳(`TOKEN_HEARTBEAT`)、命令下发,以及**轻量 request/response**——进程/窗口列表、屏幕预览缩略图等。响应以 `TOKEN_*` 回到主连接(捕获点见 §4.2)。 +- **子链接**:屏幕、终端、文件传输等**大流量/持续流**功能,服务端下 `COMMAND_*` 后客户端**新建一条 TCP 子链接**回连,数据以 `TOKEN_*` 开头,作为一个**新的 `CONTEXT_OBJECT`** 进入分派。 + +两者都由同一个 `MessageHandle(CONTEXT_OBJECT*)` 分派,靠 `ContextObject->GetClientID()` 关联到同一台主机。**判断一个 `TOKEN_*` 属于哪层,看它的数据落在哪个 context 上**: + +| 证据 | 结论 | +|------|------| +| `SystemDlg.cpp:471-499`:`GetProcessList()` 用 `m_ContextObject->Send2Client(COMMAND_PSLIST)`,`OnReceiveComplete()` 读 `m_ContextObject->InDeCompressedBuffer` | 进程/窗口列表 = **主连接 RPC** | +| `2015RemoteDlg.cpp:8527-8536`:`SendScreenPreviewRequest` 用 `ctx->Send2Client(...)`;响应 `TOKEN_SCREEN_PREVIEW_RSP` 在 `MessageHandle:6173` | 屏幕预览 = **主连接 RPC** | +| `WebService.cpp:1836-1900` + `2015RemoteDlg.cpp:6312-6351`:`COMMAND_SHELL` → 客户端建 shell 子上下文 → `TOKEN_SHELL_START`/`TOKEN_TERMINAL_START` | 终端 = **子链接** | + +### 4.2 模式 A:主连接 RPC(进程 / 窗口 / 截图) + +适用于:`list_processes`、`list_windows`、`get_screenshot`(屏幕预览)。 + +``` +MCP 工具 tools/call + └─► 主 context ctx = FindHost(device_id) + └─► ctx->Send2Client(COMMAND_PSLIST) // 主连接发 1 字节命令 +客户端处理后在主连接回 TOKEN_PSLIST + └─► MessageHandle 收到 TOKEN_PSLIST(数据已在 ctx->InDeCompressedBuffer) + ├─ if (McpServer().IsRunning() && McpServer().IsPending(devId)) + │ McpServer().TakeMainResponse(devId, ctx->InDeCompressedBuffer); // IO 线程同步拷贝 + │ break; // 跳过 WM_OPENSYSTEMDIALOG + └─ 否则 g_2015RemoteDlg->SendMessage(WM_OPENSYSTEMDIALOG, ...) // MFC 路径 +``` + +要点: + +- **无子链接、无 subCtx**:数据就在主 context 缓冲里,MCP 只需在 `MessageHandle` 的对应 `TOKEN_*` 分支里**同步拷贝**到结果缓冲,再唤醒等待者。 +- **捕获点因工具而异(必须在 IO 线程同步拷贝)**:进程/窗口列表的响应在 `InDeCompressedBuffer`(`SystemDlg::OnReceiveComplete` 读它);屏幕预览的 `TOKEN_SCREEN_PREVIEW_RSP` 则**直接读本消息的 `szBuffer`/`len`**(`2015RemoteDlg.cpp:6173-6185` 已在 case 内把 `szBuffer` 拷贝到堆消息再转主线程)。两者都是每 context 单例/单消息缓冲,主连接 recv 循环处理完本条消息后即被覆盖,故拷贝必须发生在 `MessageHandle` 内部(IO 线程),不能异步等 UI 线程。 +- **请求关联**:`get_screenshot` 有 `reqId`(`ScreenPreviewReq.reqId`)可丢弃过期响应;进程/窗口列表**无请求 id**,靠「每 host 单飞行 + 与 MFC 对话框互斥」保证正确性(见 §4.4)。 + +> **模式 A0(主连接单向命令,无响应)**:`kill_process`(`COMMAND_KILLPROCESS`)、`send_message`(`COMMAND_TALK`)这类「发完即走」的命令没有 MCP 需要的响应数据,**不需要** §4.4 的挂起注册表/超时/单飞行——`Send2Client` 后直接返回成功。它是模式 A 的退化情形,实现最简单。 + +### 4.3 模式 B:子链接流式(终端 / 完整屏幕 / 完整文件) + +适用于:`exec_command`(终端),以及未来的实时远程桌面、完整文件传输。 + +``` +MCP 工具 tools/call + └─► 登记挂起请求 m_Pending[device_id](标记 mode=B) + └─► ctx = FindHost(device_id); ctx->Send2Client(COMMAND_SHELL) // 主连接下命令 +客户端建 shell 子链接 + └─► MessageHandle 收到 TOKEN_SHELL_START / TOKEN_TERMINAL_START + ├─ if (McpServer().IsPending(devId)) + │ McpServer().TakeSubContext(devId, ContextObject); // 接管子上下文 + │ break; // 跳过弹框 + └─ 否则弹 WM_OPENSHELLDIALOG +后续 shell 输出 + └─► MessageHandle 顶部 IsTerminalContext 分支 → McpServer().OnShellData(...) 泵数据 +``` + +完全复刻 `WebService` 的 `IsTermPending`/`RegisterTerminalContext`/`OnTerminalData` 模式(`WebService.h:283-287`),但**不复用其长会话状态**——MCP 是「一次性:建链 → 收发 → 关链」。 + +### 4.4 统一挂起请求注册表 + +两种模式共用一个按 `device_id`(= `context::GetClientID()`)索引的注册表,`mode` 区分行为: + +```cpp +struct McpPendingRequest { + std::string tool; + enum Mode { A_MainRpc, B_SubLink } mode; + std::mutex mtx; + std::condition_variable cv; // 等待数据到达 + CONTEXT_OBJECT* subCtx = nullptr; // 仅模式 B:子上下文 + std::string result; // 结果缓冲(模式 A 拷贝的 buffer / 模式 B 收集的 stdout) + bool done = false; + bool timedOut = false; +}; +std::map m_Pending; // 受 m_PendingMutex 保护 +``` + +暴露给 `MessageHandle` 的钩子: + +- `bool IsPending(uint64_t device_id)` —— 该 `TOKEN_*` 是否为 MCP 触发。 +- `void TakeMainResponse(uint64_t device_id, const Buffer& buf)` —— 模式 A:拷贝缓冲、`done=true`、唤醒 `cv`。 +- `void TakeSubContext(uint64_t device_id, CONTEXT_OBJECT* ctx)` —— 模式 B:记录 `subCtx`、唤醒 `cv`。 +- `void OnShellData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len)` —— 模式 B:收集 stdout。 + +**每 host 单飞行**:同一 `device_id` 同一时刻只允许一个挂起请求。这是进程/窗口列表「无请求 id」的正确性前提,也是避免与 MFC 对话框争抢同一 `TOKEN_PSLIST` 响应的手段: + +- MCP 请求某 host 的 `list_processes` 前,若该 host 已有挂起请求或**正在被 MFC 对话框占用**(进程管理/屏幕预览浮窗开着),返回「设备忙/占用」错误。 +- 反过来,MFC 侧触发对话框时若发现 MCP 正挂起该 host,也应拒绝或排队(本阶段先按「互斥拒绝」实现,最简单)。 + +### 4.5 超时与清理(MCP 与 Web 的关键差异) + +Web 是长连接,子链接可长驻;MCP 是**一次性 request/response**。因此: + +- **超时**:`wait_for(timeout)`(建议 15–30s,可配置 `McpToolTimeoutMs`)等数据;超时返回 JSON-RPC 错误(如 `-32001`),并清理注册表。 +- **迟到回收**:数据在超时后才到达(`IsPending` 已清)→ 模式 A 直接丢弃该缓冲;模式 B 直接关闭迟到子上下文,不留孤儿会话。 +- **用完即关**:模式 B 工具收集到结果后主动关闭子上下文(与对话框/Web 的长生命周期不同)。 +- **阻塞 httplib worker**:`wait_for` 会阻塞 `HandleMcp` 所在的 httplib worker 线程。httplib 默认线程池约 8 线程,阻塞一个 20–30s 会占住一个 worker。MCP 属低频串行调用(AI 逐条调用),**本阶段可接受**;但需在 §12 显式声明,若未来需并发改用「提交任务 + 轮询结果」的异步模型。 + +--- + +## 5. 工具路线图(分阶段) + +### 5.1 工具总表 + +> 「模式」标注实现路径(A=主连接 RPC,A0=主连接单向命令[无响应],B=子链接流式,—=纯内存)。 + +| 阶段 | 工具 | 功能 | 复用协议/函数 | 模式 | 风险 | +|------|------|------|---------------|------|------| +| P1(已交付) | `list_online_hosts` | 在线主机列表 | `BuildHostJson` | — | 只读 | +| P2a(已实现) | `search_hosts` | 按名称/IP/分组/OS/在线态过滤 | `list_online_hosts` 内存过滤 | — | 只读 | +| P2a(已实现) | `get_host_detail` | 单机详情(不含活动历史) | 现成 device 字段(`BuildHostJson`) | — | 只读 | +| P2b | `list_processes` | 主机进程列表 | `COMMAND_PSLIST`→`TOKEN_PSLIST` | **A** | 只读 | +| P2b | `list_windows` | 主机窗口列表 | `COMMAND_WSLIST`→`TOKEN_WSLIST` | **A** | 只读 | +| P2b | `get_activity_history` | 单机历史活动(文本快照) | `COMMAND_QUERY_ACTIVITY`(159)→`TOKEN_REPORT_ACTIVITY`(160) | **A** | 只读 | +| P2c | `get_screenshot` | 单帧屏幕截图(JPEG) | `COMMAND_SCREEN_PREVIEW_REQ`→`TOKEN_SCREEN_PREVIEW_RSP` | **A** | 只读 | +| P2c | `list_files` | 目录列举 | `COMMAND_LIST_DRIVE`/`COMMAND_LIST_FILES`→`TOKEN_DRIVE_LIST`/`TOKEN_FILE_LIST` | **A** | 只读 | +| P3 | `exec_command` | 执行命令、返回 stdout | `COMMAND_SHELL`→`TOKEN_SHELL_START`/`TOKEN_SHELL_DATA` | **B** | **写** | +| P3 | `send_message` | 给被控端弹消息 | `COMMAND_TALK`(220) | **A0** | 写(低危) | +| P3 | `kill_process` | 结束进程 | `COMMAND_KILLPROCESS` | **A0** | **写** | +| P4(暂缓) | 实时远程桌面 / 交互终端 | 持续流 | 屏幕/终端子链接 + SSE/轮询 | **B** | 需会话模型 | + +### 5.2 Phase 2:只读观测(低风险,无写操作) + +全部只读,不改变被控端任何状态,token 模型不变(仍单一静态 token)。P2a 纯内存、零改动;P2b/P2c 引入模式 A 无头接管,但只读、可回滚。 + +### 5.3 Phase 3:安全写操作(需单独评审) + +进入 `Mcp_Design.md §1.3` 明确划出的「写操作」区。每个工具交付前需评审三点(见 §8):命令白名单、审计日志、**分组授权 + token 分级**。 + +### 5.4 Phase 4:会话/流式(暂缓) + +实时远程桌面、交互终端等需要「持续流」的能力,与当前 request/response 模型不匹配。需先决定传输方案(SSE / 提交任务 + 轮询),本期**不做**,避免过早引入复杂会话状态。 + +--- + +## 6. 分阶段实施计划 + +> 每期都给出:改动文件、接口、测试点、回滚方式。原则是**每期可独立合入、独立验收、独立回滚**。 + +### 6.1 P2a:`search_hosts` + `get_host_detail`(纯内存,零子链接)✅ 已实现 + +**改动**:仅 `McpServer.cpp`。 +- `BuildToolsListResult` 增两个工具描述 + schema。 +- `BuildToolsCall` 增两个分支,复用 `list_online_hosts` 已遍历出的主机数据做内存过滤/单机抽取(抽出 `CollectOnlineHosts` 收集 + `BuildHostItemSchema` 复用)。 +- 无任何 `MessageHandle`/`WebService`/`context` 改动。 + +**影响面**:极小,只加不改。**回滚**:revert 单文件即可。 + +**验收**:`tools/list` 返回 3 个工具;`tools/call search_hosts` 按 filter 正确过滤;`get_host_detail` 对在线主机返回正确字段、对离线/非法 id 返回明确错误。 + +> **范围修正**:原计划 `get_host_detail` 附带「活动历史」,但活动历史是 `COMMAND_QUERY_ACTIVITY`(159)→`TOKEN_REPORT_ACTIVITY`(160) 的**主连接往返**(客户端上报、服务端不落库),非纯内存字段,违反 P2a「纯内存」约束。故 P2a 的 `get_host_detail` 只返回主机实时字段,活动历史单列为 P2b 的 `get_activity_history`(模式 A)。 + +### 6.2 P2b:`list_processes` + `list_windows`(模式 A,首次无头接管) + +**改动**: +- `McpServer.h/.cpp`:加 §4.4 的 `m_Pending` 注册表、`IsPending`/`TakeMainResponse`、发起/等待/超时清理、每 host 单飞行检查。 +- `2015RemoteDlg.cpp` 的 `MessageHandle`:`TOKEN_PSLIST`/`TOKEN_WSLIST` 分支加 `if (McpServer().IsPending(devId)) { TakeMainResponse(...); break; }`(**在 IO 线程同步拷贝 `InDeCompressedBuffer`**)。 +- 工具分支:`FindHost(id)` → 登记挂起 → `ctx->Send2Client(COMMAND_PSLIST)` → 等 `cv` → 取结果 → 解析为 JSON 返回。 + +**影响面**:`MessageHandle` 只新增 `if` 分支,不影响现有 MFC 弹框路径(非 MCP 触发时行为不变)。 + +**验收**:MFC 双击进程管理弹框仍正常(回归点);MCP `list_processes` 返回 PID/名称/CPU/内存;`list_windows` 返回窗口列表;超时(如对离线主机)返回明确错误而非挂死;同 host 并发第二次调用返回「设备忙」。 + +**回滚**:revert `McpServer` + `MessageHandle` 两处改动。 + +> 同属 P2b 的 `get_activity_history`(`COMMAND_QUERY_ACTIVITY`→`TOKEN_REPORT_ACTIVITY`,主连接 RPC)机制一致,不再单列改动。 + +### 6.3 P2c:`get_screenshot` + `list_files`(模式 A) + +**`get_screenshot`(屏幕预览链路,主连接 RPC)**: + +- 复用**现有双击预览机制** `COMMAND_SCREEN_PREVIEW_REQ`(247)→`TOKEN_SCREEN_PREVIEW_RSP`(248)(`common/commands.h:416-455`,单帧 JPEG,`ScreenPreviewReq`/`ScreenPreviewRspHeader`),`ctx->Send2Client` 发请求、主连接回响应,**不建子链接、不弹框**。 +- `ScreenPreviewReq.reqId` 提供请求关联,可丢弃过期响应。 +- 客户端能力位 `CLIENT_CAP_SCREEN_PREVIEW`(0x0004,仅 Windows 客户端声明):非 Windows 主机返回「不支持」错误。 +- 若后续需要更高清/控制,再升级为完整屏幕子链接(模式 B,需 `RegisterScreenContext` 接管)。 + +**`list_files`**:复用经典文件链路 `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST` 与 `COMMAND_LIST_FILES`→`TOKEN_FILE_LIST`(模式 A)。**明确范围**:本阶段只做经典链路;项目另有 `COMMAND_GET_FOLDER`(66)、插件版 `TOKEN_DRIVE_LIST_PLUGIN`(150)、V2 文件传输,均列为非目标。文件列表可能较大,需**分页/上限**(首期限 `path` 一层、条目上限 500),避免 JSON 响应过大。 + +**验收**:`get_screenshot` 返回 JPEG(base64 或经 MCP 图片内容类型);Windows 主机正常、非 Windows 返回明确「不支持」;`list_files` 正确列目录且有大列表保护。 + +### 6.4 P3a:`exec_command`(模式 B,写操作,评审后实施) + +**改动**:复刻 Web 终端链路(§4.3),把 `m_TermPending` 换成 MCP 自己的挂起标记: +- 下 `COMMAND_SHELL` → `TOKEN_SHELL_START`/`TOKEN_SHELL_DATA` 接管 → 发送命令文本 → 收集 stdout → 关子链接 → 返回。 + +**完成判据(本工具最难的半截,必须定死)**:交互 shell 没有「命令结束」信号,采用**哨兵方案**: + +``` +实际下发: cmd ; echo __MCP_DONE___ ; echo __MCP_EXIT_$?__ +收集端: 持续累积 TOKEN_SHELL_DATA,直到同时匹配 __MCP_DONE___ 与 __MCP_EXIT___ + 取出退出码,剥离哨兵行,返回 stdout +``` + +`` 随机,避免命令自身输出误命中哨兵;整体受 `McpToolTimeoutMs` 兜底(超时强制关链 + 返回错误)。 + +**前置安全评审**(见 §8)通过后才实施。 + +**验收**:`exec_command(id, "tasklist")` 返回进程列表文本 + 退出码;白名单外命令被拒绝并审计;超时/离线返回明确错误;MFC 终端弹框不受影响。 + +--- + +## 7. 「新增工具」标准配方(可复用流程) + +后续任何人新增一个工具,按以下 6 步走: + +1. **定协议与模式**:确认复用哪个 `COMMAND_*`→`TOKEN_*` 链路,判定是模式 A(主连接 RPC)、A0(单向命令)还是 B(子链接流式),写进 §5.1 表格。 +2. **定 schema**:在 `BuildToolsListResult` 注册 `name`/`description`/`inputSchema`/`outputSchema`(description 用 `u8"..."`,见 §10)。 +3. **加 dispatch**:在 `BuildToolsCall` 里加一个 `toolName == "..."` 分支。 +4. **实现数据通路**: + - 纯内存/现成字段 → 直接实现,不进 `MessageHandle`。 + - 模式 A0(单向命令)→ 直接 `Send2Client` 后返回,无需挂起/超时。 + - 模式 A → 加 `m_Pending` 挂起 + `MessageHandle` 对应 `TOKEN_*` 加 `if (IsPending) TakeMainResponse`(IO 线程同步拷贝)。 + - 模式 B → 加 `m_Pending` 挂起 + `MessageHandle` 对应 `TOKEN_*` 加 `if (IsPending) TakeSubContext` + `OnShellData` 泵数据。 +5. **超时与清理**:带超时、迟到回收、用完即关(模式 B);每 host 单飞行(§4.5)。 +6. **验收与回滚**:写 §11 的 DoD 清单,确认回归点(对应 MFC 弹框路径不变)。 + +--- + +## 8. 安全与权限模型演进 + +| 阶段 | 鉴权 | 授权 | +|------|------|------| +| P1/P2 | 单一静态 Bearer token | 全部只读,token 即授权 | +| P3 | 引入 **token 分级 + 分组授权** | 只读 token / 完整 token 两档;写操作要求完整 token 且限定可访问分组 | +| P4 | (如需)会话级授权 | 会话内临时授权 | + +**P3 进入前必须评审的三条边界:** + +1. **命令白名单**:`exec_command` 默认只放行只读命令(`systeminfo`/`tasklist`/`ipconfig`/`netstat` 等);危险命令(`del`/`format`/下载执行/`reg add`)需显式放行或直接拒绝。 +2. **审计日志**:每次写操作记录 `host_id + tool + 参数 + token 来源(env/CFG)+ 时间`,写 `Mprintf` + 日志文件。 +3. **分组授权(新增)**:写工具必须限定「token 能操作哪些主机/分组」,**复用 Web 远程桌面已有的 `WebUser.allowed_groups` + admin/viewer 角色模型**(`WebService.cpp:1852-1877`),而非仅区分读/写。否则一个完整 token 就能对**所有**主机执行命令,越过了 Web 侧已有的分组隔离。 + +> 设计底线:MCP 的写能力与 Web 远程桌面同等级敏感,任何写工具都要按「可控、可审、可关、可分组隔离」交付,不允许「开了 MCP 就能无脑执行任意命令」。 + +--- + +## 9. 配置与开关扩展 + +延续 Phase 1 的配置风格(`settings` 段 + env 覆盖 + `UIBranding` 开关): + +| 配置键 | 默认 | 说明 | +|--------|------|------| +| `McpEnabled` | 0 | 总开关(已有) | +| `McpPort` | 6544 | 端口(已有) | +| `McpBind` | 127.0.0.1 | 绑定(已有) | +| `McpToken` | 空 | 静态 token(已有) | +| `McpReadonly` | 1 | **P3 新增**:1=仅只读工具,0=开放写工具(配合完整 token) | +| `McpCmdWhitelist` | 内置只读命令集 | **P3 新增**:`exec_command` 白名单(分号分隔) | +| `McpToolTimeoutMs` | 20000 | **P2b 新增**:数据等待超时 | + +新增写能力时,`UIBranding.h`/`FeatureFlags.h` 加对应隐藏/特性位,保持「品牌定制可裁剪」的既有约定。 + +--- + +## 10. 编码 / 兼容性规范 + +- **UTF-8 输出**:所有进入 JSON 的字符串用 `u8"..."` 前缀(项目 `/execution-charset:.936` 会把普通窄字面量编成 GBK)。已在 `list_online_hosts` 的 `description` 中验证,服务端输出正确 UTF-8。 +- **JSON 转义**:jsoncpp 会把非 ASCII 输出为 `\uXXXX`,这是合法 JSON,客户端解析即还原,无需处理。 +- **客户端能力位**:依赖客户端能力的工具(如 `get_screenshot` 的 `CLIENT_CAP_SCREEN_PREVIEW`、UTF-8 字段的 `CLIENT_CAP_UTF8`)必须先查能力位,不支持则返回明确错误而非空结果。 +- **id 一致性**:工具入参 `id` 必须与 `list_online_hosts` 返回的 `id` 同源(`context::GetClientID()` 的 uint64),可直接回填 `FindHost(uint64_t)`;仅接受**在线**主机的 id,离线返回明确错误。 +- **版本兼容**:子链接命令/结构体(`common/commands.h`)**禁止改动既有字段**;MCP 只消费既有协议,不新增命令(原则 2)。 +- **MBCS 构建**:新增 UI 文案走 `_TR()` + lang ini(GBK),与 Phase 1 一致。 + +--- + +## 11. 验收标准(Definition of Done) + +每个工具交付时,以下全部满足才算完成: + +- [ ] `tools/list` 返回该工具及完整 schema。 +- [ ] 正常输入返回正确结构化结果。 +- [ ] 非法输入(错 id / 缺参数 / 设备离线 / 不支持能力)返回**明确的 JSON-RPC 错误码**,不崩溃、不挂死。 +- [ ] 数据获取有超时、迟到回收;模式 B 用完即关(无会话泄漏)。 +- [ ] 每 host 单飞行:同 host 并发请求被拒绝而非串扰。 +- [ ] **回归点**:对应 MFC 弹框/Web 路径行为不变(非 MCP 触发时不受影响)。 +- [ ] 编码正确(UTF-8,中文无乱码)。 +- [ ] 写操作工具附带白名单/审计/分组授权(仅 P3)。 +- [ ] 无新增第三方库。 + +--- + +## 12. 风险与回滚 + +| 风险 | 缓解 | +|------|------| +| 无头接管串扰 MFC/Web 路径 | 挂起标记按 `device_id` 唯一;`if (IsPending)` 仅在 MCP 触发时成立,其余路径原样 | +| 共享 `InDeCompressedBuffer` 被覆盖 | 模式 A 在 IO 线程 `MessageHandle` 内同步拷贝,不等异步 | +| 无请求 id 的工具响应串扰 | 每 host 单飞行 + 与 MFC 对话框互斥(§4.4) | +| 阻塞 httplib worker 饿死后续请求 | 低频串行可接受;文档显式声明,未来并发改「任务+轮询」(§4.5) | +| 超时/挂死 | 所有工具强制 `wait_for(timeout)` + 迟到回收 | +| 写操作越权 | P3 前三道评审边界(白名单/审计/分组授权)+ `McpReadonly` 默认 1 | +| 一次性大改影响现有功能 | 分 P2a→P2b→P2c→P3a 四期,每期独立合入/验收/回滚 | +| 输出过大拖垮 JSON | `list_files`/进程列表等大结果设上限/分页 | + +**回滚策略**:每期改动集中在一两个文件(`McpServer.*` + 可选 `MessageHandle` 若干 `if` 分支),出错 revert 当期 commit 即可,不影响已交付的 P1。 + +--- + +## 附:建议执行顺序 + +1. **P2a**(`search_hosts` + `get_host_detail`)✅ ——纯内存、零风险,已实现,验证「多工具 dispatch」模式。 +2. **P2b**(`list_processes` + `list_windows`)——首次引入**模式 A 主连接 RPC 无头接管**,把 §4.2 的机制跑通并沉淀成 §7 配方。 +3. **P2c**(`get_screenshot` 走屏幕预览链路 + `list_files`)——补上「看」的能力,仍是只读、模式 A。 +4. **P3a**(`exec_command`)——首次引入**模式 B 子链接流式**,在 P2 的挂起机制成熟后,走完安全评审再落地写操作。 + +每完成一期即提交、验收,再进入下一期,保持「有序、平稳、循序渐进」。 diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index 1b746d9..db1b3c5 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -70,14 +70,62 @@ std::string BuildPingResult(const Json::Value& id) { return BuildResult(id, Json::Value(Json::objectValue)); } -// list_online_hosts 的 outputSchema(见 docs/Mcp_Design.md §3.3.2) -Json::Value BuildHostOutputSchema() { - Json::Value props(Json::objectValue); +// ========== P2a 通用辅助 ========== - Json::Value hostsProp(Json::objectValue); - hostsProp["type"] = "array"; - Json::Value items(Json::objectValue); - items["type"] = "object"; +// 小写化(仅 ASCII,UTF-8 多字节原样保留):用于不区分大小写的子串匹配 +std::string ToLowerAscii(const std::string& s) { + std::string r = s; + for (char& c : r) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + return r; +} + +// 不区分大小写的子串匹配 +bool ContainsCI(const std::string& haystack, const std::string& needle) { + if (needle.empty()) return true; + return ToLowerAscii(haystack).find(ToLowerAscii(needle)) != std::string::npos; +} + +// 取对象的字符串字段,缺失/非字符串返回 "" +std::string JsonStrField(const Json::Value& v, const char* key) { + if (v.isObject() && v.isMember(key) && v[key].isString()) + return v[key].asString(); + return ""; +} + +// 是否纯数字(host id 为 uint64 十进制字符串) +bool IsDigits(const std::string& s) { + if (s.empty()) return false; + for (char c : s) if (c < '0' || c > '9') return false; + return true; +} + +// 读取 tools/call 的入参(MCP 规范:params.arguments 为工具入参对象) +Json::Value GetCallArguments(const Json::Value& params) { + if (params.isObject() && params.isMember("arguments") && params["arguments"].isObject()) + return params["arguments"]; + return Json::Value(Json::objectValue); +} + +// 读取可选字符串入参,缺失返回 "" +std::string GetStringArg(const Json::Value& args, const char* key) { + return JsonStrField(args, key); +} + +// 收集所有在线主机 JSON 数组(m_cs 锁内遍历,复用 BuildHostJson 序列化,方案 C) +void CollectOnlineHosts(CMy2015RemoteDlg* parent, Json::Value& hosts) { + if (!parent) return; + EnterCriticalSection(&parent->m_cs); + for (context* ctx : parent->m_HostList) { + if (!ctx || !ctx->IsLogin()) continue; + hosts.append(BuildHostJson(ctx, parent->m_ClientMap)); + } + LeaveCriticalSection(&parent->m_cs); +} + +// ========== 工具 schema ========== + +// 单台主机字段 schema(hosts 数组元素 / 单机详情共用的形状) +Json::Value BuildHostItemSchema() { Json::Value itemProps(Json::objectValue); const char* strFields[] = { "id", "name", "remark", "ip", "os", "location", "rtt", @@ -91,7 +139,18 @@ Json::Value BuildHostOutputSchema() { Json::Value onlineProp(Json::objectValue); onlineProp["type"] = "boolean"; itemProps["online"] = onlineProp; - items["properties"] = itemProps; + return itemProps; +} + +// list_online_hosts / search_hosts 的 outputSchema(hosts 数组) +Json::Value BuildHostOutputSchema() { + Json::Value props(Json::objectValue); + + Json::Value hostsProp(Json::objectValue); + hostsProp["type"] = "array"; + Json::Value items(Json::objectValue); + items["type"] = "object"; + items["properties"] = BuildHostItemSchema(); hostsProp["items"] = items; props["hosts"] = hostsProp; @@ -104,57 +163,118 @@ Json::Value BuildHostOutputSchema() { return schema; } +// get_host_detail 的 outputSchema(单台主机) +Json::Value BuildHostDetailOutputSchema() { + Json::Value props(Json::objectValue); + + Json::Value hostProp(Json::objectValue); + hostProp["type"] = "object"; + hostProp["properties"] = BuildHostItemSchema(); + props["host"] = hostProp; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("host"); + schema["required"] = required; + return schema; +} + +// search_hosts 的 inputSchema(全部可选) +Json::Value BuildSearchHostsInputSchema() { + Json::Value props(Json::objectValue); + const char* strParams[] = { "name", "ip", "group", "os" }; + for (const char* p : strParams) { + Json::Value s(Json::objectValue); + s["type"] = "string"; + props[p] = s; + } + Json::Value onlineProp(Json::objectValue); + onlineProp["type"] = "boolean"; + props["online"] = onlineProp; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + return schema; +} + +// get_host_detail 的 inputSchema(id 必填) +Json::Value BuildGetHostDetailInputSchema() { + Json::Value props(Json::objectValue); + + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + schema["required"] = required; + return schema; +} + // tools/list std::string BuildToolsListResult(const Json::Value& id) { Json::Value result(Json::objectValue); Json::Value tools(Json::arrayValue); - Json::Value tool(Json::objectValue); - tool["name"] = "list_online_hosts"; - // 说明文字为 UTF-8:项目 /execution-charset:.936 会把普通窄字面量编译成 GBK, - // 故用 u8 前缀确保输出到 JSON 的字节是 UTF-8。 - tool["description"] = u8"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。"; + // 1) list_online_hosts + { + Json::Value tool(Json::objectValue); + tool["name"] = "list_online_hosts"; + // 说明文字为 UTF-8:项目 /execution-charset:.936 会把普通窄字面量编译成 GBK, + // 故用 u8 前缀确保输出到 JSON 的字节是 UTF-8。 + tool["description"] = u8"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。"; - Json::Value inputSchema(Json::objectValue); - inputSchema["type"] = "object"; - inputSchema["properties"] = Json::Value(Json::objectValue); - inputSchema["required"] = Json::Value(Json::arrayValue); - tool["inputSchema"] = inputSchema; + Json::Value inputSchema(Json::objectValue); + inputSchema["type"] = "object"; + inputSchema["properties"] = Json::Value(Json::objectValue); + inputSchema["required"] = Json::Value(Json::arrayValue); + tool["inputSchema"] = inputSchema; - tool["outputSchema"] = BuildHostOutputSchema(); + tool["outputSchema"] = BuildHostOutputSchema(); + + tools.append(tool); + } + + // 2) search_hosts(P2a:纯内存过滤,无子链接) + { + Json::Value tool(Json::objectValue); + tool["name"] = "search_hosts"; + tool["description"] = u8"按计算机名/备注、IP、分组、操作系统过滤在线主机。所有条件均可选、按 AND 组合;子串匹配(ASCII 不区分大小写)。只返回在线主机。"; + + tool["inputSchema"] = BuildSearchHostsInputSchema(); + tool["outputSchema"] = BuildHostOutputSchema(); + + tools.append(tool); + } + + // 3) get_host_detail(P2a:单机详情,纯内存) + { + Json::Value tool(Json::objectValue); + tool["name"] = "get_host_detail"; + tool["description"] = u8"获取单台在线主机的详细信息(id、计算机名、IP、操作系统、备注、分组、活动窗口、屏幕分辨率、客户端类型等)。"; + + tool["inputSchema"] = BuildGetHostDetailInputSchema(); + tool["outputSchema"] = BuildHostDetailOutputSchema(); + + tools.append(tool); + } - tools.append(tool); result["tools"] = tools; return BuildResult(id, result); } -// tools/call(list_online_hosts) -std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { - const Json::Value& id = root["id"]; - const Json::Value& params = root.isMember("params") ? root["params"] - : Json::Value(Json::objectValue); - - std::string toolName; - if (params.isObject() && params.isMember("name") && params["name"].isString()) { - toolName = params["name"].asString(); - } - if (toolName != "list_online_hosts") { - return BuildError(id, -32602, - "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); - } - +// tools/call:list_online_hosts +std::string BuildListOnlineHosts(const Json::Value& id, CMy2015RemoteDlg* parent) { Json::Value hosts(Json::arrayValue); - int count = 0; - if (parent) { - // 与 WebService 侧一致的锁内遍历,复用 BuildHostJson 序列化(方案 C)。 - EnterCriticalSection(&parent->m_cs); - for (context* ctx : parent->m_HostList) { - if (!ctx || !ctx->IsLogin()) continue; - hosts.append(BuildHostJson(ctx, parent->m_ClientMap)); - ++count; - } - LeaveCriticalSection(&parent->m_cs); - } + CollectOnlineHosts(parent, hosts); + int count = (int)hosts.size(); Json::Value result(Json::objectValue); Json::Value structuredContent(Json::objectValue); @@ -172,6 +292,109 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { return BuildResult(id, result); } +// tools/call:search_hosts +std::string BuildSearchHosts(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + Json::Value all(Json::arrayValue); + CollectOnlineHosts(parent, all); + + std::string fName = GetStringArg(args, "name"); + std::string fIp = GetStringArg(args, "ip"); + std::string fGroup = GetStringArg(args, "group"); + std::string fOs = GetStringArg(args, "os"); + bool hasOnline = args.isObject() && args.isMember("online") && args["online"].isBool(); + bool wantOnline = hasOnline ? args["online"].asBool() : true; + + Json::Value hosts(Json::arrayValue); + // 列表只含在线主机:显式 online=false 时直接空结果 + if (!hasOnline || wantOnline) { + for (unsigned int i = 0; i < all.size(); ++i) { + const Json::Value& h = all[i]; + if (!fName.empty()) { + std::string name = JsonStrField(h, "name"); + std::string remark = JsonStrField(h, "remark"); + if (!ContainsCI(name, fName) && !ContainsCI(remark, fName)) continue; + } + if (!fIp.empty() && !ContainsCI(JsonStrField(h, "ip"), fIp)) continue; + if (!fGroup.empty() && !ContainsCI(JsonStrField(h, "group"), fGroup)) continue; + if (!fOs.empty() && !ContainsCI(JsonStrField(h, "os"), fOs)) continue; + hosts.append(h); + } + } + int count = (int)hosts.size(); + + Json::Value result(Json::objectValue); + Json::Value structuredContent(Json::objectValue); + structuredContent["hosts"] = hosts; + result["structuredContent"] = structuredContent; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 台主机匹配。"); + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); +} + +// tools/call:get_host_detail +std::string BuildGetHostDetail(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + std::string sid = GetStringArg(args, "id"); + if (sid.empty()) { + return BuildError(id, -32602, "Missing required parameter: id"); + } + if (!IsDigits(sid)) { + return BuildError(id, -32602, "Invalid id: expected a decimal host id string"); + } + + Json::Value all(Json::arrayValue); + CollectOnlineHosts(parent, all); + for (unsigned int i = 0; i < all.size(); ++i) { + const Json::Value& h = all[i]; + if (JsonStrField(h, "id") == sid) { + Json::Value result(Json::objectValue); + Json::Value structuredContent(Json::objectValue); + structuredContent["host"] = h; + result["structuredContent"] = structuredContent; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + std::string name = JsonStrField(h, "name"); + std::string ip = JsonStrField(h, "ip"); + item["text"] = std::string(u8"主机 ") + name + " (" + ip + ")" + u8" 的详情。"; + content.append(item); + result["content"] = content; + result["isError"] = false; + + return BuildResult(id, result); + } + } + + return BuildError(id, -32002, "Host not found or offline: " + sid); +} + +// tools/call 分派 +std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { + const Json::Value& id = root["id"]; + Json::Value params = root.isMember("params") ? root["params"] : Json::Value(Json::objectValue); + + std::string toolName; + if (params.isObject() && params.isMember("name") && params["name"].isString()) { + toolName = params["name"].asString(); + } + + const Json::Value args = GetCallArguments(params); + + if (toolName == "list_online_hosts") return BuildListOnlineHosts(id, parent); + if (toolName == "search_hosts") return BuildSearchHosts(id, args, parent); + if (toolName == "get_host_detail") return BuildGetHostDetail(id, args, parent); + + return BuildError(id, -32602, + "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); +} + } // namespace //////////////////////////////////////////////////////////////////////////