diff --git a/docs/Mcp_Design.md b/docs/Mcp_Design.md new file mode 100644 index 0000000..df41e17 --- /dev/null +++ b/docs/Mcp_Design.md @@ -0,0 +1,443 @@ +# YAMA 服务端 MCP 支持方案 + +> 版本:1.0 +> 状态:Final(定稿) +> 适用范围:`server/2015Remote/`(C++/MFC 服务端,生产主流) + +--- + +## 1. 背景与目标 + +### 1.1 背景 + +MCP(Model Context Protocol)是 Anthropic 提出的开放协议,让 AI 助手(Claude Code、Claude Desktop 等)通过标准化的工具调用接口访问外部系统的能力。 + +YAMA 服务端维护了「在线主机列表」这一核心数据,如果能通过 MCP 暴露出去,用户就能在 Claude Code 里直接问「现在有多少台机器在线?」「列出所有在线的 Windows 主机」等,由 AI 调用工具获取实时数据。 + +### 1.2 本期范围(MVP) + +**只跑通 MCP,只实现一个工具**,把可扩展架构搭起来: + +- 传输方式:Streamable HTTP(`POST /mcp`,JSON-RPC 2.0 over HTTP) +- 工具数量:1 个 —— `list_online_hosts`(获取在线主机列表) +- 暴露范围:启用开关(默认关闭,经「扩展 → MCP设置」对话框开启)+ 绑定地址可配置(默认 `127.0.0.1` 仅本机)+ token 认证(env / THIS_CFG / 随机) +- 后续:逐步添加工具(进程列表、命令执行、屏幕截图等),架构不动 + +### 1.3 非目标(明确不做) + +- 不做 SSE 服务器推送(本期工具调用是 request/response 即可) +- 不做多用户/角色体系(复用现有 WebService 认证或静态 token) +- 不做写操作工具(操控主机的工具属于后续阶段,需单独评审安全边界) +- 不碰现有 Web 控制台的 WebSocket 服务 + +--- + +## 2. 现状分析 + +C++ 服务端已有大量可直接复用的资产,MCP 的落地成本因此大幅降低。 + +| 现成资产 | 位置 | 在 MCP 中的角色 | +|---|---|---| +| HTTP 服务器 | `httplib.h`(header-only,`file_server.h:34` 已用) | MCP 的 HTTP 传输层 | +| JSON 库 | `jsoncpp/json.h`(`WebService.cpp` 已用) | JSON-RPC 编解码 | +| Token 认证 | `WebServiceAuth.h`(`GenerateToken` / `ValidateToken` / `ComputeSHA256`) | 可选升级路径;MVP 用简单静态 token 比对(见 §3.5) | +| 在线主机列表 → JSON | `CWebService::BuildDeviceListJson()`(`WebService.cpp:1506`) | **工具 `list_online_hosts` 的核心逻辑** | +| 在线主机数据源 | `CMy2015RemoteDlg::m_HostList`(`2015RemoteDlg.h:345`,`m_cs` 保护)+ `context::IsLogin()` + `context::GetClientData()` | 取数入口 | +| 配置系统 | `THIS_CFG.GetInt/GetStr("settings", ...)` | MCP 端口/token 配置 | + +### 2.1 关键结论 + +- **HTTP、JSON、认证、主机列表 JSON 全部现成**,MCP 侧不需要重新造轮子。 +- **唯一需要手写的是 MCP 的 JSON-RPC 2.0 协议层**,因为 C++ 没有官方 MCP SDK(官方仅有 TS/Python/Java/Kotlin/C#/Go)。 + +### 2.2 `BuildDeviceListJson` 现状 + +`WebService.cpp:1506` 的 `CWebService::BuildDeviceListJson(const std::string& username)` 已实现: + +- 遍历 `m_pParentDlg->m_HostList`,过滤 `ctx->IsLogin()` +- 输出字段:`id` / `name` / `remark` / `ip` / `os` / `location` / `rtt` / `version` / `activeWindow` / `online` / `group` / `screen` / `clientType` +- 已处理 GBK/UTF-8 编码分叉(`activeWindow` 字段按客户端能力位选择编码页,见 `WebService.cpp:1581` 附近的 `GetClientEncoding`) + +**该方法是 `private`**(`WebService.h:160`,位于 private 段)。MCP 侧需通过公开入口复用(见 §3.4)。 + +--- + +## 3. 方案设计 + +### 3.1 总体架构 + +``` +2015Remote.exe(MFC 服务端进程) +├─ IOCP TCP 服务(被控端连接,现有) +├─ WebService(ws::Server,Web 控制台,现有,8080) +└─ McpServer(httplib,新增,默认禁用,经「MCP设置」开启后监听 127.0.0.1:6544) + POST /mcp → JSON-RPC 分发 + ├─ initialize + ├─ tools/list + └─ tools/call + └─ list_online_hosts → 复用 BuildHostJson(公共函数,§3.4) +``` + +**核心决策:独立起一个 httplib server,不挂到现有 `ws::Server`。** + +理由: +- 现有 `ws::Server` 的 `HttpHandler` 只有 `path` 参数、无 POST body(`SimpleWebSocket.h:345`),强挂 MCP 需改动其底层,侵入大、有回归风险。 +- 独立 server 零耦合、零风险,且天然满足「仅 127.0.0.1」的绑定需求。 + +### 3.2 新增文件清单 + +| 文件 | 职责 | 估算量 | +|---|---|---| +| `McpServer.h` / `McpServer.cpp` | httplib 封装、`POST /mcp` 路由、token 校验、生命周期 | ~120 行 | +| `McpProtocol.h` / `McpProtocol.cpp` | JSON-RPC 2.0 协议层:`initialize` / `tools/list` / `tools/call` 分发 | ~250 行 | +| `McpTools.h` / `McpTools.cpp` | 工具注册表 + `list_online_hosts` 实现 | ~80 行 | +| `McpSettingsDlg.h` / `McpSettingsDlg.cpp` | 「MCP设置」配置对话框(启用/端口/绑定/token + 重启提醒) | ~150 行 | +| 修改 `2015RemoteDlg.cpp` / `.h` | 启动/停止 McpServer + 菜单项接线(`ID_MCP_SETTINGS` → `OnMcpSettings`) | ~30 行 | +| 修改 `2015Remote.rc` / `resource.h` / `UIBranding.h` / `FeatureFlags.h` | `ID_MCP_SETTINGS` 菜单项 + `IDD_DIALOG_MCP_SETTINGS` 对话框资源 + `HIDE_MENU_MCP_SETTINGS` + `MF_MCP_SETTINGS` 许可位 | 少量 | + +> 头文件/实现可合并精简,实际以「一个 `McpServer` 类 + 一个工具注册表 + 一个协议分发函数」为最小形态,文件拆分仅为可读性。 + +### 3.3 协议层设计(JSON-RPC 2.0) + +MCP 是 JSON-RPC 2.0。MVP 只需实现以下方法: + +#### 3.3.1 `initialize`(握手) + +请求: +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"..."}}} +``` + +响应: +```json +{"jsonrpc":"2.0","id":1,"result":{ + "protocolVersion":"2025-06-18", + "capabilities":{"tools":{}}, + "serverInfo":{"name":"yama","version":""} +}} +``` + +#### 3.3.2 `tools/list` + +请求:`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}` + +响应: +```json +{"jsonrpc":"2.0","id":2,"result":{"tools":[{ + "name":"list_online_hosts", + "description":"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。", + "inputSchema":{"type":"object","properties":{},"required":[]}, + "outputSchema":{ + "type":"object", + "properties":{ + "hosts":{ + "type":"array", + "items":{ + "type":"object", + "properties":{ + "id":{"type":"string"}, + "name":{"type":"string"}, + "remark":{"type":"string"}, + "ip":{"type":"string"}, + "os":{"type":"string"}, + "location":{"type":"string"}, + "rtt":{"type":"string"}, + "version":{"type":"string"}, + "activeWindow":{"type":"string"}, + "online":{"type":"boolean"}, + "group":{"type":"string"}, + "screen":{"type":"string"}, + "clientType":{"type":"string"} + } + } + } + }, + "required":["hosts"] + } +}]}} +``` + +#### 3.3.3 `tools/call` + +请求: +```json +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_online_hosts","arguments":{}}} +``` + +响应(结构化输出:`structuredContent` 承载主机数组,`content` 附带可读摘要): +```json +{"jsonrpc":"2.0","id":3,"result":{ + "structuredContent":{ + "hosts":[ + {"id":"123","name":"DESKTOP-ABC","remark":"我的Windows","ip":"192.168.0.92", + "os":"Windows 10","location":"上海市","rtt":"12","version":"...", + "activeWindow":"WeChat","online":true,"group":"default","screen":"1:1920x1080","clientType":"EXE"} + ] + }, + "content":[{"type":"text","text":"共 1 台主机在线。"}], + "isError":false +}} +``` + +#### 3.3.4 可选实现 + +- `ping`:返回空 result(便于健康检查) +- `notifications/initialized`:客户端通知,可忽略 + +#### 3.3.5 错误响应(JSON-RPC 2.0 标准) + +协议层必须处理以下错误,返回标准 `error` 结构(`id` 回填请求 id): + +| 场景 | code | message | +|---|---|---| +| 请求体不是合法 JSON | `-32700` | Parse error | +| 请求结构不合法(缺 jsonrpc/method 等) | `-32600` | Invalid Request | +| 方法未实现 | `-32601` | Method not found | +| 参数非法 | `-32602` | Invalid params | +| 内部错误 | `-32603` | Internal error | + +示例(方法未实现): +```json +{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}} +``` + +#### 3.3.6 传输层约定(Streamable HTTP 最小子集) + +- 客户端 `POST /mcp`,请求头 `Content-Type: application/json`、`Accept: application/json, text/event-stream`。 +- 服务端返回 `Content-Type: application/json`(MVP 工具调用为 request/response,无需 SSE 流式)。 +- `initialize` 响应可回填 `Mcp-Session-Id` 头用于会话管理;MVP 单会话可省略,实现时对照最新 MCP 规范确认。 +- 仅接受 `POST /mcp`,其余路径/方法返回 404/405。 + +### 3.4 工具设计:`list_online_hosts` + +#### 取数方案(已定:抽公共函数) + +`BuildDeviceListJson` 目前是 `CWebService` 的 private 方法,且依赖 `m_pParentDlg`。为满足「MCP 与 WebService 是平级数据消费者、不互相依赖」的原则,采用**方案 C:抽公共函数**。 + +```cpp +// 公共函数:提取单台在线主机的完整数据(含 GBK/UTF-8 编码处理),返回 UTF-8 的 Json::Value +Json::Value BuildHostJson(context* ctx, _ClientList* clientMap); +``` + +- `CWebService::BuildDeviceListJson` 内部改调该公共函数,Web 侧签名与输出不变(零回归)。 +- `McpServer` 侧持 `CMy2015RemoteDlg*`(`SetParentDlg`),在 `m_cs` 锁内遍历 `m_HostList`、`IsLogin()` 过滤,对每台主机调 `BuildHostJson`,组装为结构化输出。 +- **职责边界**:用户名/分组过滤(`username` → `allowed_groups`)、`m_cs` 加锁、以及外层结构(Web 的 `{"cmd","devices"}` vs MCP 的 `{"hosts"}`)属于**调用方**,不进公共函数;`BuildHostJson` 只负责「单台主机 → `Json::Value`」的字段序列化与编码转换。其中 `remark` 依赖 `m_ClientMap->GetClientMapData(id, MAP_NOTE)`,故 `clientMap` 作为参数传入。 + +选型说明:方案 A(MCP 依赖 `CWebService` 单例)依赖方向错误,且 `WebSvrPort=0` 时 `m_pParentDlg` 为空导致取不到数;方案 B(MCP 自持父对话框复制逻辑)违反 DRY、重复易错的编码处理。故取 C。 + +#### 返回内容(全量,含实时信息) + +**保留 `BuildDeviceListJson` 的全部字段,不做精简**,以支持「AI 协助监督客户端运行状态」的场景: + +- 基础标识:`id` / `name` / `remark` / `ip` / `os` / `location` / `group` / `version` / `clientType` / `screen` +- 实时信息:`rtt`(延迟)、`activeWindow`(当前活动窗口,即被控端正在做什么) + +输出结构 `{"hosts": [...]}`,每台主机一个对象,字段与 §3.3.2 的 `outputSchema` 一致。 + +### 3.5 安全设计 + +- **绑定地址**:默认 `127.0.0.1`(仅本机回环,其他机器无法访问);可经 `McpBind` 配置为 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程调用。 + - 非回环绑定时 token 经明文 HTTP 传输,需强 token,并建议配合防火墙限制源 IP 或启用 HTTPS(项目已有 WebHTTPS 实践)。 +- **静态 token 校验**:每个 `POST /mcp` 请求校验 `Authorization: Bearer ` header;不匹配返回 HTTP 401。 +- **token 来源**(优先顺序):环境变量 `YAMA_MCP_TOKEN` → `THIS_CFG` 的 `McpToken` → 两者皆空则**随机生成**。 + - 前两者任一非空即作为固定 token;仅当两者皆空时才随机生成(仅本次进程运行期间有效,进程重启后重新生成不同的 token)。 + - 随机生成时**必须将 token 输出到日志**(`Mprintf`),否则用户无从获知、无法在 Claude Code 侧配置。 + - 环境变量名 `YAMA_MCP_TOKEN` 建议同 `BRAND_WEB_ENV_VAR` 一样在 `UIBranding.h` 定义为宏(如 `BRAND_MCP_ENV_VAR`),避免散落硬编码字符串。 +- **只读边界**:MVP 仅一个只读工具,不暴露任何写/操控能力。 +- **HTTP 方法限制**:仅接受 `POST /mcp`,其余返回 404/405。 + +### 3.6 配置设计 + +沿用现有 `THIS_CFG`(`settings` 节): + +| 配置键 | 默认值 | 说明 | +|---|---|---| +| `McpEnabled` | `0` | MCP 总开关:`0` = 禁用(默认),`1` = 启用。经「扩展 → MCP设置」对话框设置 | +| `McpPort` | `6544` | MCP 监听端口;仅当 `McpEnabled=1` 时生效;未配置用默认 `6544` | +| `McpBind` | `127.0.0.1` | 监听地址;默认仅本机;可设 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程 | +| `McpToken` | 空(UI 默认随机值) | 静态 token;对话框内「token 必填、默认随机值」——首次打开若为空则预填随机并随保存持久化。运行时优先级:env `YAMA_MCP_TOKEN` → `McpToken` → 皆空则随机生成(见 §3.5) | + +> 与旧版「`McpPort=0` 即禁用」不同:启用与否改由独立的 `McpEnabled` 开关控制,`McpPort` 回归纯端口语义,二者解耦。 + +### 3.7 生命周期接入 + +在 `2015RemoteDlg.cpp` Web 服务启动块(约 `2048-2081`)之后,新增 MCP 启动逻辑。`McpServer` 与 `CWebService` 同为单例:提供 `static McpServer& Instance()` 与全局 `inline McpServer& McpServer()` 访问器(仿 `WebService()`,见 `WebService.h` 末尾的全局访问器写法)。 + +```cpp +auto mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); // 默认禁用 +auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544); +auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); +if (mcpEnabled) { + // token 优先顺序:env YAMA_MCP_TOKEN → THIS_CFG McpToken → 皆空则随机(仅本次进程有效) + const char* envTok = getenv("YAMA_MCP_TOKEN"); + std::string mcpToken; + if (envTok && *envTok) { + mcpToken = envTok; + } else { + mcpToken = THIS_CFG.GetStr("settings", "McpToken", ""); + } + if (mcpToken.empty()) { + mcpToken = GenerateRandomToken(); // 32 hex 随机串 + Mprintf("[McpServer] YAMA_MCP_TOKEN / McpToken 均未设置,本次进程随机 token:%s\n", mcpToken.c_str()); + } + McpServer().SetParentDlg(this); // 方案 C:MCP 需遍历 m_HostList,须持父对话框指针 + McpServer().SetToken(mcpToken); + if (!McpServer().Start(mcpBind, mcpPort)) { + Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); + } +} +``` + +并在退出路径(`OnDestroy` / `ExitInstance` 对应位置)调用 `McpServer().Stop()`。 + +### 3.8 「MCP设置」配置对话框 + +MCP 默认不启用,通过主对话框「扩展 → MCP设置」菜单项打开配置对话框来开启。菜单项位置:`2015Remote.rc` 扩展菜单(`POPUP "扩展(&X)"`)内、「地理信息(&L)」子菜单(纯真数据库 / IP2Region)的 `END` 之后、「插件设置」之前。 + +#### 3.8.1 菜单与资源接线 + +1. **`resource.h`**:新增命令 ID(取当前空闲段,经查 `33077` 空闲): + ```cpp + #define ID_MCP_SETTINGS 33077 + ``` +2. **`2015Remote.rc`**:在「地理信息(&L)」子菜单 `END` 之后、`插件设置` 之前插入: + ``` + MENUITEM "MCP设置(&M)...", ID_MCP_SETTINGS + ``` + 并新增对话框资源 `IDD_DIALOG_MCP_SETTINGS`(「启用 MCP」复选框 + 端口/绑定地址/token 三个编辑框 + 确定/取消按钮)。 +3. **`UIBranding.h`**:扩展菜单隐藏开关新增一行(默认 `0` 显示): + ```cpp + #define HIDE_MENU_MCP_SETTINGS 0 // MCP设置 + ``` +4. **`FeatureFlags.h`**:新增运行时许可位,占用 MenuFlags 保留段 `[43-63]` 的首位(紧跟 `MF_REQUEST_AUTH` 之后): + ```cpp + #define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS + ``` +5. **`2015RemoteDlg.cpp`**:`#include "McpSettingsDlg.h"`,然后消息映射 + 处理器 + 剪枝: + ```cpp + ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings) + // ... + void CMy2015RemoteDlg::OnMcpSettings() + { + CMcpSettingsDlg dlg(this); + dlg.DoModal(); + } + ``` + 并在扩展菜单剪枝块(`pExtMenu`,约 `2015RemoteDlg.cpp:1225` 之后)追加: + ```cpp + if (SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS)) + pExtMenu->DeleteMenu(ID_MCP_SETTINGS, MF_BYCOMMAND); + ``` + +> **实现注意**:`2015Remote.rc` 为 UTF-16 编码,菜单与对话框资源须由 VS 资源编辑器维护(手工改文本易破坏编码与资源 ID 引用)。`IDD_DIALOG_MCP_SETTINGS` 需在 `resource.h` 分配独立空闲的 `IDD_` 数值(与 `ID_MCP_SETTINGS` 分开);对话框内控件 ID(如 `IDC_CHK_ENABLE_MCP` / `IDC_EDIT_MCP_PORT` / `IDC_EDIT_MCP_BIND` / `IDC_EDIT_MCP_TOKEN`)同理。 + +#### 3.8.2 对话框字段与默认值 + +| 控件 | 绑定配置键 | 默认值 | 说明 | +|---|---|---|---| +| 「启用 MCP」复选框 | `McpEnabled` | 未勾选(`0`) | 勾选后持久化 `McpEnabled=1` | +| 端口 | `McpPort` | `6544` | 数字编辑框,仅当启用时有效 | +| 绑定地址 | `McpBind` | `127.0.0.1` | 默认仅本机;可改 `0.0.0.0` / 内网 IP | +| Token | `McpToken` | 随机值 | 必填;为空时预填随机 token | + +#### 3.8.3 初始化与保存 + +打开时读 `THIS_CFG` 回填;token 为空则当场生成随机值预填(32 hex)。保存时落盘并弹重启提醒: + +```cpp +BOOL CMcpSettingsDlg::OnInitDialog() +{ + CDialogLangEx::OnInitDialog(); + int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); + int port = THIS_CFG.GetInt("settings", "McpPort", 6544); + std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); + std::string tok = THIS_CFG.GetStr("settings", "McpToken", ""); + if (tok.empty()) tok = GenerateRandomToken(); // 预填随机,随保存持久化 + // 回填到控件:m_bEnabled / m_nPort / m_strBind / m_strToken + return TRUE; +} + +void CMcpSettingsDlg::OnBnClickedBtnSave() +{ + // 从控件收集:enabled / port / bind / token + if (enabled && token.empty()) { + MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING); + return; + } + THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0); + THIS_CFG.SetInt("settings", "McpPort", port); + THIS_CFG.SetStr("settings", "McpBind", bind); + THIS_CFG.SetStr("settings", "McpToken", token); + MessageBoxL(_TR("MCP 设置已保存。\n启用/端口/绑定地址/Token 的改动需重启程序生效。"), + _TR("提示"), MB_ICONINFORMATION); + CDialogLangEx::OnOK(); +} +``` + +#### 3.8.4 与运行时 token 逻辑的衔接 + +对话框的「默认随机 token」是把随机值**写入 `McpToken` 并持久化**,于是下次启动走 `env → McpToken` 的固定 token 分支;运行时「两者皆空则随机」分支仅在用户从未通过对话框设置过(或手动清空 token)时兜底,两者不冲突。`GenerateRandomToken()` 为 MCP 侧新增的小工具函数(32 hex 随机串,可用 `rand_s` / `BCryptGenRandom` 实现;如需可复用 `WebServiceAuth.h` 的 `ComputeSHA256` 派生)。 + +--- + +## 4. 实施步骤 + +| 步骤 | 内容 | 验收 | +|---|---|---| +| 1 | 新建 `McpProtocol`:JSON-RPC 分发 + `initialize`/`tools/list`/`tools/call`(含 jsoncpp 解析/序列化) | 单元自测:手写请求串,验证响应 | +| 2 | 新建 `McpServer`:httplib 起 `127.0.0.1:6544`,`POST /mcp` 路由 + token 校验(含随机生成) | curl 请求返回 401/正常 | +| 3 | 抽公共函数 `BuildHostJson`,`CWebService::BuildDeviceListJson` 改调之 | Web 输出不变,零回归 | +| 4 | 注册 `list_online_hosts` 工具:handler 持父对话框、`m_cs` 锁内遍历、调 `BuildHostJson` 组装 | `tools/call` 返回主机列表 | +| 5 | 新增 `McpSettingsDlg` + 菜单项接线(`ID_MCP_SETTINGS` / `OnMcpSettings` / `HIDE_MENU_MCP_SETTINGS` / `IDD_DIALOG_MCP_SETTINGS` 资源) | 对话框可启用/禁用、配置端口/绑定/token,保存落盘并提示重启 | +| 6 | 接入 `2015RemoteDlg` 启动/停止(按 `McpEnabled` 门控)+ 配置 | 服务端启动后端口监听正常 | +| 7 | Claude Code 接入验证(见 §5) | 见验收标准 | + +--- + +## 5. 验收标准 + +1. 在「MCP设置」对话框启用并重启服务端后,`127.0.0.1:6544` 监听正常,外部地址无法访问;未启用时该端口不监听。 +2. 无 token 的请求返回 HTTP 401;错误 token 返回 401;正确 token 通过。 +3. `curl` 模拟 `initialize` → `tools/list` → `tools/call` 全流程,返回符合 JSON-RPC 2.0 与 MCP 规范。 +4. Claude Code 侧: + ```bash + claude mcp add yama --transport http http://127.0.0.1:6544/mcp -H "Authorization: Bearer " + ``` + `claude mcp list` 能看到 `yama`,在对话中问「列出在线主机」能正确触发工具并返回列表。 +5. 现有 Web 控制台、IOCP 主服务功能无回归。 + +--- + +## 6. 风险与约束 + +| 风险 | 说明 | 应对 | +|---|---|---| +| 无官方 C++ MCP SDK | 需手写 JSON-RPC 协议层 | 仅实现 3 个方法,量小可控;协议稳定后再考虑抽库 | +| MCP 规范版本演进 | protocolVersion 字符串需与客户端匹配 | 实现时对照最新规范;`initialize` 返回可协商版本 | +| 并发安全 | 遍历 `m_HostList` 需持 `m_cs` | MCP 与 WebService 采用相同的「`m_cs` 锁内遍历」模式 | +| 字符编码 | GBK/UTF-8 分叉 | 复用公共函数 `BuildHostJson` 的编码处理,不自造 | +| token 存储 | `THIS_CFG` 的 token 会明文落盘;随机 token 会打印到日志 | 本地回环 + 只读,风险可控;敏感场景建议仅用 env 并妥善保管 | + +--- + +## 7. 后续扩展规划(本期不做) + +- 工具:`get_host_processes`(进程列表)、`execute_command`(命令执行)、`capture_screen`(截图)等 +- 写操作工具的安全边界评审(操作对象、权限、审计) +- 认证升级:复用 `WebServiceAuth` 的签名 token / 过期机制 +- 分组过滤:`list_online_hosts` 加 `group` 参数 + +--- + +## 8. 决策记录 + +**决策记录(全部已定):** +- ✅ **启用开关**:新增 `McpEnabled`,默认 `0`(禁用)。MCP 默认不启动,须用户在「扩展 → MCP设置」对话框手动开启。 +- ✅ **端口**:`McpPort` 可配置,未配置默认 `6544`(纯端口语义,不再兼任启用开关)。 +- ✅ **token**:优先顺序 env `YAMA_MCP_TOKEN` → `THIS_CFG.McpToken` → 皆空则随机生成(仅本次进程有效,打印到日志)。对话框内 token 必填、默认随机值(预填并随保存持久化到 `McpToken`)。 +- ✅ **绑定地址**:默认 `127.0.0.1`(仅本机);可配置为 `0.0.0.0` 或内网 IP(如 `192.168.0.92`)以支持多人协作跨机调用。 +- ✅ **配置入口**:主对话框「扩展」菜单,位于「地理信息(&L)」子菜单下方新增「MCP设置」菜单项,打开独立配置对话框。 +- ✅ **生效方式**:MCP 启用/端口/绑定/token 的改动均需重启服务端生效,对话框保存后弹窗提醒「重启程序生效」。 +- ✅ **取数方案**:C(抽公共函数 `BuildHostJson`,MCP 与 WebService 平级复用)。 +- ✅ **返回字段**:全量,含实时信息(`rtt` / `activeWindow`),支持 AI 监督场景。 +- ✅ **序列化**:`structuredContent` + `outputSchema`,`content` 附带可读摘要。 diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index 86b267b..64d72b5 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -43,6 +43,8 @@ #include #include "common/skCrypter.h" #include "common/commands.h" +#include "McpServer.h" +#include "McpSettingsDlg.h" #include "common/md5.h" #include #include @@ -976,6 +978,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx) ON_COMMAND(ID_SHELLCODE_TEST_AES_BIN, &CMy2015RemoteDlg::OnShellcodeTestAesBin) ON_COMMAND(ID_TOOL_RELOAD_PLUGINS, &CMy2015RemoteDlg::OnToolReloadPlugins) ON_COMMAND(ID_TOOL_PLUGIN_SETTINGS, &CMy2015RemoteDlg::OnToolPluginSettings) + ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings) ON_COMMAND(ID_TRIGGER_SETTINGS, &CMy2015RemoteDlg::OnTriggerSettings) ON_COMMAND(ID_SHELLCODE_AES_C_ARRAY, &CMy2015RemoteDlg::OnShellcodeAesCArray) ON_COMMAND(ID_PARAM_KBLOGGER, &CMy2015RemoteDlg::OnParamKblogger) @@ -1217,13 +1220,63 @@ VOID CMy2015RemoteDlg::CreateSolidMenu() pExtMenu->DeleteMenu(ID_BACKUP_DATA, MF_BYCOMMAND); if (SHOULD_HIDE_MENU(HIDE_MENU_IMPORT_DATA, MF_IMPORT_DATA)) pExtMenu->DeleteMenu(ID_IMPORT_DATA, MF_BYCOMMAND); - if (SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS)) - pExtMenu->DeleteMenu(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND); - if (SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST)) - pExtMenu->DeleteMenu(ID_PLUGIN_REQUEST, MF_BYCOMMAND); if (SHOULD_HIDE_MENU(HIDE_MENU_FRPS_FOR_SUB, MF_FRPS_FOR_SUB)) pExtMenu->DeleteMenu(ID_FRPS_FOR_SUB, MF_BYCOMMAND); + // 动态插入「MCP设置」菜单项(2015Remote.rc 为 UTF-16 不便编辑,改运行时插入, + // 位于「地理信息」之后、「插件设置」之前,见 docs/Mcp_Design.md §3.8.1) + if (!SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS)) { + UINT mcpFlags = MF_BYCOMMAND; + if (THIS_CFG.GetInt("settings", "McpEnabled", 0)) { + mcpFlags |= MF_CHECKED; // 已启用 MCP 则菜单打勾 + } + pExtMenu->InsertMenu(ID_TOOL_PLUGIN_SETTINGS, mcpFlags, + ID_MCP_SETTINGS, _TR("MCP设置(&M)...")); + } + + // 将 3 个插件相关菜单项收拢到「插件」二级菜单(位于「MCP设置」之下、原「插件设置」之前, + // 顺序不变:插件设置 / 刷新插件 / 获取插件)。 + // 注意:先删除顶层项、再按位置插入子菜单,避免同一命令 ID 同时存在于子菜单与顶层时, + // MF_BYCOMMAND 的 DeleteMenu/InsertMenu 因重复 ID 而误删/误定位子菜单项。 + { + // 记录「插件设置」的索引,作为「插件」子菜单的插入点 + int pluginPos = -1; + for (UINT i = 0; i < pExtMenu->GetMenuItemCount(); i++) { + if (pExtMenu->GetMenuItemID(i) == ID_TOOL_PLUGIN_SETTINGS) { + pluginPos = (int)i; + break; + } + } + + if (pluginPos >= 0) { + // 先删除 3 个顶层项 + pExtMenu->DeleteMenu(ID_TOOL_PLUGIN_SETTINGS, MF_BYCOMMAND); + pExtMenu->DeleteMenu(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND); + pExtMenu->DeleteMenu(ID_PLUGIN_REQUEST, MF_BYCOMMAND); + + // 再创建「插件」子菜单 + CMenu pluginMenu; + pluginMenu.CreatePopupMenu(); + pluginMenu.AppendMenu(MF_STRING, ID_TOOL_PLUGIN_SETTINGS, _TR("插件设置(&S)")); + if (!SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS)) + pluginMenu.AppendMenu(MF_STRING, ID_TOOL_RELOAD_PLUGINS, _TR("刷新插件(&P)")); + if (!SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST)) + pluginMenu.AppendMenu(MF_STRING, ID_PLUGIN_REQUEST, _TR("获取插件(&P)")); + + // 继承原 3 项的菜单图标(见上方 SetMenuItemBitmaps 区) + pluginMenu.SetMenuItemBitmaps(ID_TOOL_PLUGIN_SETTINGS, MF_BYCOMMAND, &m_bmOnline[53], &m_bmOnline[53]); + if (!SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS)) + pluginMenu.SetMenuItemBitmaps(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND, &m_bmOnline[43], &m_bmOnline[43]); + if (!SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST)) + pluginMenu.SetMenuItemBitmaps(ID_PLUGIN_REQUEST, MF_BYCOMMAND, &m_bmOnline[44], &m_bmOnline[44]); + + // 插入到原「插件设置」位置(MF_BYPOSITION) + pExtMenu->InsertMenu((UINT)pluginPos, MF_BYPOSITION | MF_POPUP, + (UINT_PTR)pluginMenu.GetSafeHmenu(), _TR("插件(&P)")); + pluginMenu.Detach(); // HMENU 所有权转交 pExtMenu,避免局部析构销毁子菜单 + } + } + // --- 扩展菜单 - 语言设置子菜单 --- CMenu* pLangMenu = FindSubMenuByCommand(pExtMenu, ID_CHANGE_LANG); if (pLangMenu) { @@ -2080,6 +2133,34 @@ BOOL CMy2015RemoteDlg::OnInitDialog() } } + // Start MCP service(默认禁用;经「扩展 → MCP设置」开启后监听,见 docs/Mcp_Design.md §3.7) + if (THIS_CFG.GetInt("settings", "McpEnabled", 0)) { + auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544); + auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); + + // token 优先顺序:env BRAND_MCP_ENV_VAR → THIS_CFG McpToken → 皆空则随机(仅本次进程有效) + const char* envTok = getenv(BRAND_MCP_ENV_VAR); + std::string mcpToken; + if (envTok && *envTok) { + mcpToken = envTok; + } else { + mcpToken = THIS_CFG.GetStr("settings", "McpToken", ""); + } + if (mcpToken.empty()) { + mcpToken = GenerateRandomToken(); + Mprintf("[McpServer] %s / McpToken 均未设置,本次进程随机 token:%s\n", + BRAND_MCP_ENV_VAR, mcpToken.c_str()); + } + + McpServer().SetParentDlg(this); + McpServer().SetToken(mcpToken); + if (!McpServer().Start(mcpBind, mcpPort)) { + Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); + } else { + Mprintf("McpServer started on %s:%d\n", mcpBind.c_str(), mcpPort); + } + } + UPDATE_SPLASH(18, "正在初始化文件上传模块..."); int ret = InitFileUpload({}, {}, GetHMAC(), 64, 50, Logf, GetUpperHash()); g_hKeyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, AfxGetInstanceHandle(), 0); @@ -3185,6 +3266,12 @@ void CMy2015RemoteDlg::ApplyFrpSettings() auto arr = StringToVector(ports, ';'); int fileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", 8080); + // MCP 端口:仅当「启用 MCP 且绑定 0.0.0.0」时纳入 FRP 暴露(与监听/Web 取并集,不覆盖)。 + int mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); + std::string mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); + int mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544); + bool exposeMcp = (mcpEnabled != 0) && (mcpBind == "0.0.0.0") && (mcpPort > 0); + // 为每个服务端生成独立配置文件 (index=0 用 frpc.ini 保持兼容) for (size_t idx = 0; idx < servers.size(); ++idx) { std::string path = (idx == 0) ? basePath : baseNoExt + "." + std::to_string(idx) + ".ini"; @@ -3214,6 +3301,14 @@ void CMy2015RemoteDlg::ApplyFrpSettings() cfg.SetInt(name, "remote_port", fileServerPort); cfg.SetStr(name, "proxy_protocol_version", "v2"); // 传递真实客户端 IP } + if (exposeMcp) { + std::string name = BRAND_NET_PREFIX "-MCP-" + std::to_string(mcpPort); + cfg.SetStr(name, "type", "tcp"); + cfg.SetInt(name, "local_port", mcpPort); + cfg.SetInt(name, "remote_port", mcpPort); + // 不设置 proxy_protocol_version:MCP 由 httplib 提供、不解析 Proxy Protocol, + // 且 MCP 以 Bearer token 鉴权、无需透传真实 IP(监听/Web 端口才需要 PP2)。 + } } } @@ -3805,6 +3900,12 @@ void CMy2015RemoteDlg::Release() WebService().Stop(); } + // Stop MCP service + if (McpServer().IsRunning()) { + Mprintf("Stopping McpServer...\n"); + McpServer().Stop(); + } + UninitFileUpload(); DeletePopupWindow(TRUE); CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口 @@ -10756,6 +10857,16 @@ void CMy2015RemoteDlg::OnToolPluginSettings() dlg.DoModal(); } +void CMy2015RemoteDlg::OnMcpSettings() +{ + CMcpSettingsDlg dlg(this); + if (dlg.DoModal() != IDOK) return; + + // 保存后重生成 frpc.ini:若已启用 MCP 且绑定 0.0.0.0,则把 MCP 端口纳入 FRP 暴露列表 + // (与监听/Web 端口取并集,互不覆盖)。MCP 生效仍需重启,此处仅为下次启动准备配置。 + ApplyFrpSettings(); +} + void CMy2015RemoteDlg::OnTriggerSettings() { if (m_DllList.empty()) { diff --git a/server/2015Remote/2015RemoteDlg.h b/server/2015Remote/2015RemoteDlg.h index b2c7f66..9eb4d4b 100644 --- a/server/2015Remote/2015RemoteDlg.h +++ b/server/2015Remote/2015RemoteDlg.h @@ -638,6 +638,7 @@ public: afx_msg void OnMasterTrail(); afx_msg void OnCancelShare(); afx_msg void OnWebRemoteControl(); + afx_msg void OnMcpSettings(); afx_msg void OnProxyPortAutorun(); afx_msg void OnScreenpreviewLoop(); afx_msg void OnMenuCompress(); diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj b/server/2015Remote/2015Remote_vs2015.vcxproj index 94da9ef..c404ef5 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj +++ b/server/2015Remote/2015Remote_vs2015.vcxproj @@ -317,6 +317,7 @@ + @@ -325,6 +326,8 @@ + + @@ -387,6 +390,9 @@ + + + NotUsing NotUsing diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj.filters b/server/2015Remote/2015Remote_vs2015.vcxproj.filters index 96c5687..1636b89 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj.filters +++ b/server/2015Remote/2015Remote_vs2015.vcxproj.filters @@ -84,6 +84,9 @@ + + + @@ -164,6 +167,7 @@ + @@ -189,6 +193,8 @@ + + diff --git a/server/2015Remote/FeatureFlags.h b/server/2015Remote/FeatureFlags.h index 28ee837..888a9a3 100644 --- a/server/2015Remote/FeatureFlags.h +++ b/server/2015Remote/FeatureFlags.h @@ -88,7 +88,9 @@ typedef struct FeatureFlags { #define MF_MASTER_TRAIL (1ULL << 41) // HIDE_MENU_MASTER_TRAIL #define MF_REQUEST_AUTH (1ULL << 42) // HIDE_MENU_REQUEST_AUTH -// [43-63] Reserved +#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS + +// [44-63] Reserved // ============================================================ diff --git a/server/2015Remote/HostJson.cpp b/server/2015Remote/HostJson.cpp new file mode 100644 index 0000000..acd6a44 --- /dev/null +++ b/server/2015Remote/HostJson.cpp @@ -0,0 +1,117 @@ +#include "stdafx.h" +#include "HostJson.h" +#include "HostInfo.h" // _ClientList / MAP_NOTE +#include "context.h" // context 接口 + ONLINELIST_* 枚举 +#include "2015RemoteDlg.h" // GetClientEncoding 辅助函数(UTF-8 能力位判断) + +#ifndef _WIN64 +#ifdef _DEBUG +#pragma comment(lib, "jsoncpp/jsoncppd.lib") +#else +#pragma comment(lib, "jsoncpp/jsoncpp.lib") +#endif +#else +#ifdef _DEBUG +#pragma comment(lib, "jsoncpp/jsoncpp_x64d.lib") +#else +#pragma comment(lib, "jsoncpp/jsoncpp_x64.lib") +#endif +#endif + +// Helper: Convert ANSI (GBK) string to UTF-8 +// (自 WebService.cpp 抽出,供 BuildDeviceListJson 与 BuildHostJson 共用) +static std::string AnsiToUtf8(const CString& str) { + if (str.IsEmpty()) return ""; +#ifdef _UNICODE + // Unicode build: CString is already UTF-16, convert to UTF-8 + int len = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); + if (len <= 0) return ""; + std::string result(len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, str, -1, &result[0], len, NULL, NULL); + return result; +#else + // MBCS build: CString is ANSI (GBK), need to convert to UTF-16 first, then to UTF-8 + int wlen = MultiByteToWideChar(CP_ACP, 0, str, -1, NULL, 0); + if (wlen <= 0) return ""; + std::wstring wstr(wlen - 1, L'\0'); + MultiByteToWideChar(CP_ACP, 0, str, -1, &wstr[0], wlen); + + int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL); + if (u8len <= 0) return ""; + std::string result(u8len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &result[0], u8len, NULL, NULL); + return result; +#endif +} + +Json::Value BuildHostJson(context* ctx, _ClientList* clientMap) { + Json::Value device; + + if (!ctx) return device; // 空 context → 返回 null Json::Value + + // Use string for ID to avoid JavaScript number precision loss + device["id"] = std::to_string(ctx->GetClientID()); + + CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME); + device["name"] = AnsiToUtf8(name); + + // 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE) + if (clientMap) { + CString remark = clientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE); + if (!remark.IsEmpty()) { + device["remark"] = AnsiToUtf8(remark); + } + } + + CString ip = ctx->GetClientData(ONLINELIST_IP); + device["ip"] = AnsiToUtf8(ip); + + CString os = ctx->GetClientData(ONLINELIST_OS); + device["os"] = AnsiToUtf8(os); + + CString location = ctx->GetClientData(ONLINELIST_LOCATION); + device["location"] = AnsiToUtf8(location); + + CString rtt = ctx->GetClientData(ONLINELIST_PING); + device["rtt"] = AnsiToUtf8(rtt); + + CString version = ctx->GetClientData(ONLINELIST_VERSION); + device["version"] = AnsiToUtf8(version); + + // 活动窗口编码由客户端能力位决定:新客户端是 UTF-8,老客户端是 CP_ACP(默认 936)。 + // 不能像其它字段那样无脑 AnsiToUtf8——会把新客户端的 UTF-8 字节再当 GBK 双重编码。 + CString activeWindow = ctx->GetClientData(ONLINELIST_LOGINTIME); + std::string activeWindowU8; + if (!activeWindow.IsEmpty()) { + UINT cp = GetClientEncoding(ctx); + int wlen = MultiByteToWideChar(cp, 0, activeWindow, -1, NULL, 0); + if (wlen > 1) { + std::wstring w(wlen - 1, L'\0'); + MultiByteToWideChar(cp, 0, activeWindow, -1, &w[0], wlen); + int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL); + if (u8len > 1) { + activeWindowU8.resize(u8len - 1); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &activeWindowU8[0], u8len, NULL, NULL); + } + } + } + device["activeWindow"] = activeWindowU8; + device["online"] = true; + + // Add device group to response + std::string deviceGroup = ctx->GetGroupName(); + if (deviceGroup.empty()) deviceGroup = "default"; + device["group"] = deviceGroup; + + // Get screen info from client's reported resolution + // Format: "n:MxN" where n=monitor count, M=width, N=height + CString resolution = ctx->GetAdditionalData(RES_RESOLUTION); + if (!resolution.IsEmpty()) { + device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080" + } + + CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE); + device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE" + + return device; +} diff --git a/server/2015Remote/HostJson.h b/server/2015Remote/HostJson.h new file mode 100644 index 0000000..5d679b3 --- /dev/null +++ b/server/2015Remote/HostJson.h @@ -0,0 +1,15 @@ +#pragma once + +#include "jsoncpp/json.h" + +class context; +class _ClientList; + +// 公共函数:将单台在线主机 context 序列化为 JSON(UTF-8,含 GBK/UTF-8 编码处理)。 +// 供 Web UI(CWebService::BuildDeviceListJson)与 MCP 工具(list_online_hosts)复用, +// 避免两处重复易错的编码转换(见 docs/Mcp_Design.md §3.4 方案 C)。 +// +// 职责边界:本函数只负责「单台主机 → Json::Value」的字段序列化与编码转换。 +// 分组/用户过滤、m_cs 加锁、外层结构(Web 的 {"cmd","devices"} vs MCP 的 {"hosts"}) +// 属于调用方,不进本函数。调用方须在 m_cs 锁内调用。 +Json::Value BuildHostJson(context* ctx, _ClientList* clientMap); diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp new file mode 100644 index 0000000..1b746d9 --- /dev/null +++ b/server/2015Remote/McpServer.cpp @@ -0,0 +1,291 @@ +#include "stdafx.h" +#include "McpServer.h" +#include "jsoncpp/json.h" +#include "HostJson.h" // BuildHostJson(单台主机序列化公共函数) +#include "context.h" // context 接口 +#include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员(m_HostList/m_cs/m_ClientMap)+ VERSION_STR + +#include + +#ifndef _WIN64 +#ifdef _DEBUG +#pragma comment(lib, "jsoncpp/jsoncppd.lib") +#else +#pragma comment(lib, "jsoncpp/jsoncpp.lib") +#endif +#else +#ifdef _DEBUG +#pragma comment(lib, "jsoncpp/jsoncpp_x64d.lib") +#else +#pragma comment(lib, "jsoncpp/jsoncpp_x64.lib") +#endif +#endif + +namespace { + +// Json::Value → 紧凑 JSON 字符串 +std::string JsonToString(const Json::Value& v) { + Json::StreamWriterBuilder b; + b["indentation"] = ""; + return Json::writeString(b, v); +} + +// JSON-RPC 2.0 成功响应 +std::string BuildResult(const Json::Value& id, const Json::Value& result) { + Json::Value resp(Json::objectValue); + resp["jsonrpc"] = "2.0"; + resp["id"] = id; + resp["result"] = result; + return JsonToString(resp); +} + +// JSON-RPC 2.0 错误响应 +std::string BuildError(const Json::Value& id, int code, const std::string& msg) { + Json::Value resp(Json::objectValue); + resp["jsonrpc"] = "2.0"; + resp["id"] = id; + Json::Value err(Json::objectValue); + err["code"] = code; + err["message"] = msg; + resp["error"] = err; + return JsonToString(resp); +} + +// initialize 握手(MCP 规范:protocolVersion + capabilities + serverInfo) +std::string BuildInitializeResult(const Json::Value& id) { + Json::Value result(Json::objectValue); + result["protocolVersion"] = "2025-06-18"; + Json::Value caps(Json::objectValue); + caps["tools"] = Json::Value(Json::objectValue); + result["capabilities"] = caps; + Json::Value serverInfo(Json::objectValue); + serverInfo["name"] = "yama"; + serverInfo["version"] = VERSION_STR; + result["serverInfo"] = serverInfo; + return BuildResult(id, result); +} + +// ping 健康检查:返回空 result +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); + + Json::Value hostsProp(Json::objectValue); + hostsProp["type"] = "array"; + Json::Value items(Json::objectValue); + items["type"] = "object"; + Json::Value itemProps(Json::objectValue); + const char* strFields[] = { + "id", "name", "remark", "ip", "os", "location", "rtt", + "version", "activeWindow", "group", "screen", "clientType" + }; + for (const char* f : strFields) { + Json::Value p(Json::objectValue); + p["type"] = "string"; + itemProps[f] = p; + } + Json::Value onlineProp(Json::objectValue); + onlineProp["type"] = "boolean"; + itemProps["online"] = onlineProp; + items["properties"] = itemProps; + hostsProp["items"] = items; + props["hosts"] = hostsProp; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("hosts"); + 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、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。"; + + 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(); + + 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)); + } + + 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); + } + + 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); +} + +} // namespace + +////////////////////////////////////////////////////////////////////////// +// CMcpServer Implementation +////////////////////////////////////////////////////////////////////////// + +CMcpServer& CMcpServer::Instance() { + static CMcpServer instance; + return instance; +} + +CMcpServer::CMcpServer() { + m_server.Post("/mcp", [this](const httplib::Request& req, httplib::Response& res) { + HandleMcp(req, res); + }); +} + +CMcpServer::~CMcpServer() { + Stop(); // 兜底:确保监听线程 join,避免 std::thread 析构触发 terminate +} + +bool CMcpServer::Start(const std::string& bind, int port) { + if (m_running.load()) return true; // 已在运行 + + m_thread = std::thread([this, bind, port]() { + m_server.listen(bind, port); + }); + + // 给 listen 一点时间绑定端口;httplib::Server::is_running() 在 listen 内部置位。 + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + m_running.store(m_server.is_running()); + return m_running.load(); +} + +void CMcpServer::Stop() { + m_server.stop(); + if (m_thread.joinable()) { + m_thread.join(); + } + m_running.store(false); +} + +void CMcpServer::HandleMcp(const httplib::Request& req, httplib::Response& res) { + res.set_header("Content-Type", "application/json"); + + // 静态 token 校验:Authorization: Bearer (Start 前经 SetToken 保证非空) + if (req.get_header_value("Authorization") != ("Bearer " + m_token)) { + res.status = 401; + res.set_content(BuildError(Json::nullValue, -32000, "Unauthorized"), "application/json"); + return; + } + + // 解析 JSON-RPC 请求体 + Json::Value root; + Json::CharReaderBuilder rbuilder; + std::string errs; + std::istringstream iss(req.body); + if (!Json::parseFromStream(rbuilder, iss, &root, &errs) || !root.isObject()) { + res.set_content(BuildError(Json::nullValue, -32700, "Parse error"), "application/json"); + return; + } + + // 通知(无 id)→ 不返回 JSON-RPC 响应(如 notifications/initialized) + if (!root.isMember("id")) { + res.status = 202; + res.set_content("", "application/json"); + return; + } + + // 结构校验:缺 method + if (!root.isMember("method") || !root["method"].isString()) { + res.set_content(BuildError(root["id"], -32600, "Invalid Request"), "application/json"); + return; + } + + std::string method = root["method"].asString(); + + if (method == "initialize") { + res.set_content(BuildInitializeResult(root["id"]), "application/json"); + return; + } + if (method == "ping") { + res.set_content(BuildPingResult(root["id"]), "application/json"); + return; + } + if (method == "tools/list") { + res.set_content(BuildToolsListResult(root["id"]), "application/json"); + return; + } + if (method == "tools/call") { + res.set_content(BuildToolsCall(root, m_parent), "application/json"); + return; + } + + // 未实现的方法 + res.set_content(BuildError(root["id"], -32601, "Method not found"), "application/json"); +} + +// rand_s:Windows CRT 加密安全随机源(基于系统 CSPRNG)。其声明需在 前 +// 定义 _CRT_RAND_S;为避免依赖 PCH 的包含顺序,这里手动声明其导出原型(errno_t == int)。 +extern "C" int __cdecl rand_s(unsigned int* randomValue); + +std::string GenerateRandomToken() { + static const char hex[] = "0123456789abcdef"; + std::string out; + out.reserve(32); + for (int i = 0; i < 16; ++i) { + unsigned int v = 0; + if (rand_s(&v) != 0) { + // rand_s 失败(罕见):退化为时间 + 地址熵,保证仍返回非空 token。 + v = (unsigned int)(GetTickCount() ^ (ULONG_PTR)&out); + } + out.push_back(hex[(v >> 4) & 0xF]); + out.push_back(hex[v & 0xF]); + } + return out; +} diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h new file mode 100644 index 0000000..fcd5038 --- /dev/null +++ b/server/2015Remote/McpServer.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include + +// httplib 与 Windows 头部的 min/max 宏冲突,按 file_server.h 的既有约定处理。 +#undef min +#undef max +#include "httplib.h" +#ifndef max +#define max(a,b) (((a) > (b)) ? (a) : (b)) +#endif +#ifndef min +#define min(a,b) (((a) < (b)) ? (a) : (b)) +#endif + +#pragma comment(lib, "ws2_32.lib") + +class CMy2015RemoteDlg; + +// MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。 +// 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听 +// (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。 +class CMcpServer { +public: + static CMcpServer& Instance(); + + void SetParentDlg(CMy2015RemoteDlg* pDlg) { m_parent = pDlg; } + void SetToken(const std::string& token) { m_token = token; } + + bool Start(const std::string& bind, int port); + void Stop(); + bool IsRunning() const { return m_running.load(); } + +private: + CMcpServer(); + ~CMcpServer(); + CMcpServer(const CMcpServer&) = delete; + CMcpServer& operator=(const CMcpServer&) = delete; + + // POST /mcp 处理器:token 校验 + JSON-RPC 分发。 + void HandleMcp(const httplib::Request& req, httplib::Response& res); + + httplib::Server m_server; + std::thread m_thread; + std::atomic m_running{false}; + std::string m_token; + CMy2015RemoteDlg* m_parent = nullptr; +}; + +// 全局访问器(仿 WebService(),见 WebService.h 末尾) +inline CMcpServer& McpServer() { return CMcpServer::Instance(); } + +// 生成 32 hex(128-bit)随机 token;供运行时兜底与「MCP设置」对话框预填复用。 +std::string GenerateRandomToken(); diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp new file mode 100644 index 0000000..1d45fc5 --- /dev/null +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -0,0 +1,191 @@ +#include "stdafx.h" +#include "McpSettingsDlg.h" +#include "LangManager.h" // _TR / MessageBoxL +#include "McpServer.h" // GenerateRandomToken +#include "2015Remote.h" // THIS_CFG (GetThisCfg) +#include + +#ifdef _DEBUG +#define new DEBUG_NEW +#endif + +namespace { + +// 构造一个无控件的 DLGTEMPLATE:cdit=0,控件由 OnInitDialog 动态创建。 +// (与 ZstaPickerDlg.cpp 内同名函数一致) +void BuildDialogTemplate(std::vector& out, LPCWSTR caption, short cx, short cy) +{ + out.clear(); + auto append = [&](const void* p, size_t n) { + const BYTE* b = (const BYTE*)p; + out.insert(out.end(), b, b + n); + }; + auto appendW = [&](WORD v) { + out.push_back((BYTE)(v & 0xFF)); + out.push_back((BYTE)((v >> 8) & 0xFF)); + }; + auto appendWStr = [&](LPCWSTR s) { + size_t n = wcslen(s); + append(s, (n + 1) * sizeof(WCHAR)); + }; + + DLGTEMPLATE dt = { 0 }; + dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | + WS_POPUP | WS_CAPTION | WS_SYSMENU; + dt.dwExtendedStyle = 0; + dt.cdit = 0; + dt.x = 0; dt.y = 0; + dt.cx = cx; dt.cy = cy; + append(&dt, sizeof(dt)); + appendW(0); // no menu + appendW(0); // default dialog class + appendWStr(caption); // caption + appendW(8); // font point size + appendWStr(L"MS Shell Dlg"); // typeface + + while (out.size() % 4) out.push_back(0); // DWORD align +} + +} // namespace + +BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog) +END_MESSAGE_MAP() + +CMcpSettingsDlg::CMcpSettingsDlg(CWnd* parent) + : CDialog((LPCTSTR)NULL, parent) +{ +} + +INT_PTR CMcpSettingsDlg::DoModal() +{ + USES_CONVERSION; + CString title = _TR("MCP设置"); + BuildDialogTemplate(m_Template, T2CW(title), 320, 150); + InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); + return CDialog::DoModal(); +} + +BOOL CMcpSettingsDlg::OnInitDialog() +{ + CDialog::OnInitDialog(); + + CRect cli; + GetClientRect(&cli); + + // 占位 rect,真正布局在 LayoutControls 里 + CRect r0(0, 0, 10, 10); + + m_btnEnable.Create(_TR("启用 MCP"), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, + r0, this, IDC_MCP_ENABLE); + m_lblPort.Create(_TR("端口"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); + m_editPort.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL, + r0, this, IDC_MCP_PORT); + m_lblBind.Create(_TR("绑定地址"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); + m_editBind.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL, + r0, this, IDC_MCP_BIND); + m_lblToken.Create(_TR("Token"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); + m_editToken.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL, + r0, this, IDC_MCP_TOKEN); + m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON, + r0, this, IDOK); + m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, + r0, this, IDCANCEL); + + // 子控件继承对话框字体 (DS_SETFONT) + HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0); + if (hFont) { + m_btnEnable.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_lblPort.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_editPort.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_lblBind.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_editBind.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_lblToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + } + + // 回填配置(首次打开且 token 为空时预填随机值,随保存持久化) + int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); + int port = THIS_CFG.GetInt("settings", "McpPort", 6544); + std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); + std::string tok = THIS_CFG.GetStr("settings", "McpToken", ""); + if (tok.empty()) tok = GenerateRandomToken(); + + m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); + m_editPort.SetWindowText(CString(std::to_string(port).c_str())); + m_editBind.SetWindowText(CString(bind.c_str())); + m_editToken.SetWindowText(CString(tok.c_str())); + + LayoutControls(cli.Width(), cli.Height()); + return TRUE; +} + +void CMcpSettingsDlg::OnOK() +{ + CString sPort, sBind, sToken; + m_editPort.GetWindowText(sPort); + m_editBind.GetWindowText(sBind); + m_editToken.GetWindowText(sToken); + bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED); + + // 端口校验:1-65535 + int port = atoi(CT2A(sPort)); + if (port < 1 || port > 65535) { + MessageBoxL(_TR("端口需为 1-65535 的数字"), _TR("提示"), MB_ICONWARNING); + return; + } + + std::string bind = CT2A(sBind); + if (bind.empty()) bind = "127.0.0.1"; + + std::string token = CT2A(sToken); + if (enabled && token.empty()) { + MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING); + return; + } + + // 落盘 + THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0); + THIS_CFG.SetInt("settings", "McpPort", port); + THIS_CFG.SetStr("settings", "McpBind", bind); + THIS_CFG.SetStr("settings", "McpToken", token); + + // 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示) + MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + + _TR("启用/端口/绑定地址/Token 的改动需重启程序生效。"), + _TR("提示"), MB_ICONINFORMATION); + + CDialog::OnOK(); +} + +void CMcpSettingsDlg::LayoutControls(int cx, int cy) +{ + const int margin = 14; + const int labelW = 72; + const int rowH = 24; + const int gap = 10; + const int btnW = 88; + const int btnH = 26; + + int y = margin; + m_btnEnable.MoveWindow(margin, y, cx - margin * 2, 22); + y += 30; + + m_lblPort.MoveWindow(margin, y, labelW, rowH); + m_editPort.MoveWindow(margin + labelW, y - 2, 100, rowH); + y += rowH + gap; + + m_lblBind.MoveWindow(margin, y, labelW, rowH); + m_editBind.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH); + y += rowH + gap; + + m_lblToken.MoveWindow(margin, y, labelW, rowH); + m_editToken.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH); + y += rowH + gap; + + int bottomY = cy - margin - btnH; + m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH); + m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH); +} diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h new file mode 100644 index 0000000..dc4fce3 --- /dev/null +++ b/server/2015Remote/McpSettingsDlg.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +// 「MCP设置」配置对话框:启用 MCP / 端口 / 绑定地址 / Token。 +// 因 2015Remote.rc 为 UTF-16 编码、不宜手工编辑,参照 ZstaPickerDlg 用 +// InitModalIndirect + 内存 DLGTEMPLATE 程序化创建(见 docs/Mcp_Design.md §3.8)。 +class CMcpSettingsDlg : public CDialog +{ +public: + explicit CMcpSettingsDlg(CWnd* parent = nullptr); + + virtual INT_PTR DoModal(); + +protected: + virtual BOOL OnInitDialog(); + virtual void OnOK(); + + DECLARE_MESSAGE_MAP() + +private: + enum CtrlId { + IDC_MCP_ENABLE = 1001, // 「启用 MCP」复选框 + IDC_MCP_PORT = 1002, // 端口编辑框 + IDC_MCP_BIND = 1003, // 绑定地址编辑框 + IDC_MCP_TOKEN = 1004, // Token 编辑框 + }; + + CButton m_btnEnable; + CStatic m_lblPort, m_lblBind, m_lblToken; + CEdit m_editPort, m_editBind, m_editToken; + CButton m_btnOK, m_btnCancel; + + std::vector m_Template; // 内存 DLGTEMPLATE 字节 + + void LayoutControls(int cx, int cy); +}; diff --git a/server/2015Remote/UIBranding.h b/server/2015Remote/UIBranding.h index aedb908..9af49bb 100644 --- a/server/2015Remote/UIBranding.h +++ b/server/2015Remote/UIBranding.h @@ -171,6 +171,7 @@ #define HIDE_MENU_RELOAD_PLUGINS 0 // 重新加载插件 #define HIDE_MENU_PLUGIN_REQUEST 0 // 插件请求 #define HIDE_MENU_FRPS_FOR_SUB 0 // 下级FRP +#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置 // --- 扩展菜单 - 语言子菜单 --- #define HIDE_MENU_CHANGE_LANG 0 // 更改语言 @@ -298,6 +299,8 @@ // 用的 master password 解耦——后者一旦泄漏影响面更大,应避免在公网登录 // 时复用。与 Go 服务端的 YAMA_WEB_ADMIN_PASS 语义一致。 #define BRAND_WEB_ENV_VAR "YAMA_WEB_ADMIN_PASS" +// MCP 服务端静态 token 环境变量(优先级高于 settings/McpToken) +#define BRAND_MCP_ENV_VAR "YAMA_MCP_TOKEN" // --- 宽字符版本(自动生成)--- #define BRAND_APP_NAME_W _T(BRAND_APP_NAME) diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 58e1af7..8b34f09 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -6,6 +6,7 @@ #include "2015RemoteDlg.h" #include "Server.h" // For CONTEXT_OBJECT #include "jsoncpp/json.h" +#include "HostJson.h" // BuildHostJson(单台主机序列化公共函数) #include "WebPage.h" #include "SimpleWebSocket.h" #include "common/commands.h" @@ -88,31 +89,6 @@ static void ClearNonce(void* ws_ptr) { s_ClientNonces.erase(ws_ptr); } -// Helper: Convert ANSI (GBK) string to UTF-8 -static std::string AnsiToUtf8(const CString& str) { - if (str.IsEmpty()) return ""; -#ifdef _UNICODE - // Unicode build: CString is already UTF-16, convert to UTF-8 - int len = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); - if (len <= 0) return ""; - std::string result(len - 1, '\0'); - WideCharToMultiByte(CP_UTF8, 0, str, -1, &result[0], len, NULL, NULL); - return result; -#else - // MBCS build: CString is ANSI (GBK), need to convert to UTF-16 first, then to UTF-8 - int wlen = MultiByteToWideChar(CP_ACP, 0, str, -1, NULL, 0); - if (wlen <= 0) return ""; - std::wstring wstr(wlen - 1, L'\0'); - MultiByteToWideChar(CP_ACP, 0, str, -1, &wstr[0], wlen); - - int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL); - if (u8len <= 0) return ""; - std::string result(u8len - 1, '\0'); - WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &result[0], u8len, NULL, NULL); - return result; -#endif -} - ////////////////////////////////////////////////////////////////////////// // CWebService Implementation ////////////////////////////////////////////////////////////////////////// @@ -1544,71 +1520,9 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) { if (!allowed) continue; // Skip device not in allowed groups } - Json::Value device; - // Use string for ID to avoid JavaScript number precision loss - device["id"] = std::to_string(ctx->GetClientID()); - - CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME); - device["name"] = AnsiToUtf8(name); - - // 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE) - // 例如 hostname="A6" + remark="我的Windows" → web 显示"A6 (我的Windows)" - if (m_pParentDlg->m_ClientMap) { - CString remark = m_pParentDlg->m_ClientMap->GetClientMapData( - ctx->GetClientID(), MAP_NOTE); - if (!remark.IsEmpty()) { - device["remark"] = AnsiToUtf8(remark); - } - } - - CString ip = ctx->GetClientData(ONLINELIST_IP); - device["ip"] = AnsiToUtf8(ip); - - CString os = ctx->GetClientData(ONLINELIST_OS); - device["os"] = AnsiToUtf8(os); - - CString location = ctx->GetClientData(ONLINELIST_LOCATION); - device["location"] = AnsiToUtf8(location); - - CString rtt = ctx->GetClientData(ONLINELIST_PING); - device["rtt"] = AnsiToUtf8(rtt); - - CString version = ctx->GetClientData(ONLINELIST_VERSION); - device["version"] = AnsiToUtf8(version); - - // 活动窗口编码由客户端能力位决定:新客户端是 UTF-8,老客户端是 CP_ACP(默认 936)。 - // 不能像其它字段那样无脑 AnsiToUtf8——会把新客户端的 UTF-8 字节再当 GBK 双重编码。 - CString activeWindow = ctx->GetClientData(ONLINELIST_LOGINTIME); - std::string activeWindowU8; - if (!activeWindow.IsEmpty()) { - UINT cp = GetClientEncoding(ctx); - int wlen = MultiByteToWideChar(cp, 0, activeWindow, -1, NULL, 0); - if (wlen > 1) { - std::wstring w(wlen - 1, L'\0'); - MultiByteToWideChar(cp, 0, activeWindow, -1, &w[0], wlen); - int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL); - if (u8len > 1) { - activeWindowU8.resize(u8len - 1); - WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &activeWindowU8[0], u8len, NULL, NULL); - } - } - } - device["activeWindow"] = activeWindowU8; - device["online"] = true; - - // Add device group to response - device["group"] = deviceGroup; - - // Get screen info from client's reported resolution - // Format: "n:MxN" where n=monitor count, M=width, N=height - CString resolution = ctx->GetAdditionalData(RES_RESOLUTION); - if (!resolution.IsEmpty()) { - device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080" - } - - CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE); - device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE" - + // 单台主机序列化委托给公共函数 BuildHostJson(与 MCP 侧复用, + // 字段与编码转换逻辑保持一致,见 docs/Mcp_Design.md §3.4 方案 C)。 + Json::Value device = BuildHostJson(ctx, m_pParentDlg->m_ClientMap); res["devices"].append(device); } LeaveCriticalSection(&m_pParentDlg->m_cs); diff --git a/server/2015Remote/lang/en_US.ini b/server/2015Remote/lang/en_US.ini index 815b061..55e8dc8 100644 --- a/server/2015Remote/lang/en_US.ini +++ b/server/2015Remote/lang/en_US.ini @@ -427,6 +427,7 @@ Rar +AES -> bin ļ=Generate+AES -> bin File AES binļ=Test AES bin File 滻ͼ(&R)=&Replace Icon +(&P)=&Plugins ˢ²(&P)=Refresh &Plugins (&A)=&Auth Generate (&M)=&Master Generate @@ -1978,3 +1979,12 @@ FRPC Զ ѽ %d IP ӺƳ=Removed %d IP(s) from blacklist ѻָ %d ͻ˵Ȩ״̬=Restored authorization for %d client(s) δҵҪ IP=No IPs found that need unbanning +; ===== MCP Settings ===== +MCP=MCP Settings +MCP(&M)...=MCP Settings(&M)... + MCP=Enable MCP +󶨵ַ=Bind Address +˿Ϊ 1-65535 =Port must be a number between 1 and 65535 +Token Ϊ=Token cannot be empty +MCP ѱ档=MCP settings saved. +/˿/󶨵ַ/Token ĸĶЧ=Changes to enable/port/bind address/token take effect after restart. diff --git a/server/2015Remote/lang/zh_TW.ini b/server/2015Remote/lang/zh_TW.ini index 00c8825..d630c66 100644 --- a/server/2015Remote/lang/zh_TW.ini +++ b/server/2015Remote/lang/zh_TW.ini @@ -427,6 +427,7 @@ Rar +AES -> bin ļ=a+AES -> bin n AES binļ=yԇ AES bin n 滻ͼ(&R)=QDʾ(&R) +(&P)=ʽ(&P) ˢ²(&P)=ʽ(&P) (&A)=ܴaa(&A) (&M)=خa(&M) @@ -1969,3 +1970,12 @@ FRPC Զ ѽ %d IP ӺƳ=ь %d IP ĺƳ ѻָ %d ͻ˵Ȩ״̬=ѻ֏ %d ͑˵ڙB δҵҪ IP=δҵҪ IP +; ===== MCP Settings ===== +MCP=MCPO +MCP(&M)...=MCPO(&M)... + MCP= MCP +󶨵ַ=λַ +˿Ϊ 1-65535 =BӲ 1-65535 Ĕ +Token Ϊ=Token ܞ +MCP ѱ档=MCP Oу档 +/˿/󶨵ַ/Token ĸĶЧ=/BӲ/λַ/Token ׃†ӳʽЧ diff --git a/server/2015Remote/resource.h b/server/2015Remote/resource.h index 407c1ee..6b39021 100644 --- a/server/2015Remote/resource.h +++ b/server/2015Remote/resource.h @@ -999,6 +999,7 @@ #define ID_MSGLOG_UNBAN_IP 33081 #define ID_WEB_REMOTE_CONTROL 33044 #define ID_TOOL_PLUGIN_SETTINGS 33045 +#define ID_MCP_SETTINGS 33077 #define ID_33046 33046 #define ID_PROXY_PORT_AUTORUN 33047 #define ID_TRIGGER_SETTINGS 33048