Feature: Add remote_keyboard MCP tool (key_down / key_up / key_press / type)

Implement milestone M2b of the MCP remote-control design: inject keyboard
events through an existing screen sub-connection.

- Extract the MSG64 keyboard construction from WebService::HandleKey into a
  shared inline BuildKeyMsg64 helper in WebService.h, and have HandleKey use
  it (behaviour-preserving) so the Web and MCP paths cannot drift.
- Add BuildRemoteKeyboard with four actions: key_down / key_up / key_press
  (key name -> VK via MapKeyNameToVk, plus CTRL/ALT/SHIFT/WIN modifiers) and
  type (per-character VkKeyScanA mapping, ASCII only, newline/tab -> Enter/Tab).
  The batch is sent as one [COMMAND_SCREEN_CONTROL][MSG64*N] packet over the
  screen sub-connection under the existing Begin/EndScreenCtrlAction busy
  discipline, then audited via WM_SHOWERRORMSG.

Non-ASCII text is rejected (-32602) and must go through remote_clipboard +
Ctrl+V (milestone M4), matching the design's clipboard path for CJK input.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-25 13:22:00 +02:00
parent c844ba7614
commit f04d892ac8
3 changed files with 301 additions and 35 deletions

View File

@@ -1132,8 +1132,11 @@ Json::Value BuildRemoteOpenInputSchema();
Json::Value BuildRemoteOpenOutputSchema();
Json::Value BuildRemoteCloseInputSchema();
Json::Value BuildRemoteCloseOutputSchema();
Json::Value BuildRemoteKeyboardInputSchema();
Json::Value BuildRemoteKeyboardOutputSchema();
std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
// tools/list
std::string BuildToolsListResult(const Json::Value& id) {
@@ -1357,6 +1360,14 @@ std::string BuildToolsListResult(const Json::Value& id) {
tool["outputSchema"] = BuildRemoteCloseOutputSchema();
tools.append(tool);
}
{
Json::Value tool(Json::objectValue);
tool["name"] = "remote_keyboard";
tool["description"] = u8"向已建立的远程控制会话注入键盘事件。action 可选 key_down / key_up / key_press / type前三种需 keyWindows 虚拟键名,如 ENTER/TAB/F5/LEFT/CTRL/ALT/SHIFT/WINtype 需 text仅 ASCII 文本;非 ASCII 请用 remote_clipboard + Ctrl+V。modifiers 可选CTRL/ALT/SHIFT/WIN";
tool["inputSchema"] = BuildRemoteKeyboardInputSchema();
tool["outputSchema"] = BuildRemoteKeyboardOutputSchema();
tools.append(tool);
}
}
result["tools"] = tools;
@@ -2845,6 +2856,263 @@ std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy
return BuildResult(id, result);
}
// ===== P5remote_keyboard键盘注入key_down / key_up / key_press / type=====
// 键名 → 虚拟键码(对齐 Windows VK_* 去掉前缀,见设计 §4.3)。
// 支持:单字符 A-Z/0-9、F1-F24、命名键、修饰键。未知返回 0VK 0 非真实键)。
static int MapKeyNameToVk(const std::string& nameIn) {
std::string n;
for (char c : nameIn)
n += (c >= 'a' && c <= 'z') ? (char)(c - 'a' + 'A') : c;
if (n.size() == 1) {
unsigned char c = (unsigned char)n[0];
if (c >= 'A' && c <= 'Z') return c; // 'A'..'Z' == VK_A..VK_Z
if (c >= '0' && c <= '9') return c; // '0'..'9' == VK_0..VK_9
return 0; // 标点等请走 type
}
// F1..F24
if (n.size() >= 2 && n.size() <= 3 && n[0] == 'F') {
int fnum = atoi(n.c_str() + 1);
if (fnum >= 1 && fnum <= 24) return VK_F1 + (fnum - 1);
return 0;
}
struct Named { const char* name; int vk; };
static const Named kNames[] = {
{"ENTER", VK_RETURN}, {"RETURN", VK_RETURN}, {"TAB", VK_TAB}, {"SPACE", VK_SPACE},
{"ESC", VK_ESCAPE}, {"ESCAPE", VK_ESCAPE}, {"BACKSPACE", VK_BACK},
{"DELETE", VK_DELETE}, {"DEL", VK_DELETE}, {"INSERT", VK_INSERT}, {"INS", VK_INSERT},
{"HOME", VK_HOME}, {"END", VK_END}, {"PAGEUP", VK_PRIOR}, {"PRIOR", VK_PRIOR},
{"PAGEDOWN", VK_NEXT}, {"NEXT", VK_NEXT},
{"LEFT", VK_LEFT}, {"RIGHT", VK_RIGHT}, {"UP", VK_UP}, {"DOWN", VK_DOWN},
{"CAPSLOCK", VK_CAPITAL}, {"CAPITAL", VK_CAPITAL}, {"NUMLOCK", VK_NUMLOCK},
{"SCROLLLOCK", VK_SCROLL}, {"PRINTSCREEN", VK_SNAPSHOT}, {"SNAPSHOT", VK_SNAPSHOT},
{"PAUSE", VK_PAUSE}, {"APPS", VK_APPS}, {"CONTEXTMENU", VK_APPS},
{"CTRL", VK_CONTROL}, {"CONTROL", VK_CONTROL}, {"ALT", VK_MENU}, {"MENU", VK_MENU},
{"SHIFT", VK_SHIFT}, {"WIN", VK_LWIN}, {"WINDOWS", VK_LWIN}, {"CMD", VK_LWIN}, {"SUPER", VK_LWIN},
};
for (const Named& e : kNames)
if (n == e.name) return e.vk;
return 0;
}
// 是否修饰键modifiers 白名单CTRL/ALT/SHIFT/WIN
static bool IsModifierVk(int vk) {
return vk == VK_CONTROL || vk == VK_MENU || vk == VK_SHIFT || vk == VK_LWIN || vk == VK_RWIN;
}
// 解析可选 modifiers 数组JSON 字符串数组)→ 修饰键 VK 序列。非法返回 false 并填 err。
static bool ParseModifiers(const Json::Value& args, std::vector<int>& out, std::string& err) {
out.clear();
if (!args.isMember("modifiers")) return true;
const Json::Value& mods = args["modifiers"];
if (mods.isNull()) return true;
if (!mods.isArray()) { err = "modifiers must be an array of strings"; return false; }
for (const auto& m : mods) {
if (out.size() >= 8) { err = "too many modifiers (max 8)"; return false; }
int vk = MapKeyNameToVk(m.asString());
if (vk == 0 || !IsModifierVk(vk)) {
err = "invalid modifier: " + (m.isString() ? m.asString() : std::string("(non-string)"));
return false;
}
out.push_back(vk);
}
return true;
}
// 是否纯 ASCIItype 走物理键事件,只能可靠注入 ASCII/ANSI见设计 §6.4
static bool IsAscii(const std::string& s) {
for (unsigned char c : s) if (c >= 0x80) return false;
return true;
}
// type逐字符 VkKeyScanA 映射为「Shift↓字符↓ 字符↑Shift↑回车/制表符映射为按键。
// 注VkKeyScanA 在服务端按服务端线程键盘布局解析字符→虚拟键;仅限 ASCII0..127)时
// 各布局的字母/数字/常用标点虚拟键一致,跨布局差异可忽略(非 ASCII 一律走剪贴板)。
static void BuildTypeBatch(const std::string& text, std::vector<MSG64>& batch) {
for (unsigned char ch : text) {
if (ch == '\r' || ch == '\n') {
batch.push_back(BuildKeyMsg64(VK_RETURN, true, false));
batch.push_back(BuildKeyMsg64(VK_RETURN, false, false));
continue;
}
if (ch == '\t') {
batch.push_back(BuildKeyMsg64(VK_TAB, true, false));
batch.push_back(BuildKeyMsg64(VK_TAB, false, false));
continue;
}
SHORT scan = VkKeyScanA((char)ch);
if (scan == -1) continue; // 无法映射(控制字符等)→ 跳过
int vk = scan & 0xFF;
bool needShift = (scan >> 8) & 1;
if (needShift) batch.push_back(BuildKeyMsg64(VK_SHIFT, true, false));
batch.push_back(BuildKeyMsg64(vk, true, false));
batch.push_back(BuildKeyMsg64(vk, false, false));
if (needShift) batch.push_back(BuildKeyMsg64(VK_SHIFT, false, false));
}
}
Json::Value BuildRemoteKeyboardInputSchema() {
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 sid(Json::objectValue);
sid["type"] = "string";
sid["description"] = u8"remote_open 返回的 session_id";
props["session_id"] = sid;
Json::Value act(Json::objectValue);
act["type"] = "string";
act["description"] = u8"动作key_down / key_up / key_press / type";
props["action"] = act;
Json::Value key(Json::objectValue);
key["type"] = "string";
key["description"] = u8"按键key_down/key_up/key_press 用Windows 虚拟键名(如 ENTER/TAB/F5/LEFT/CTRL/ALT/SHIFT/WIN或单个字母/数字";
props["key"] = key;
Json::Value text(Json::objectValue);
text["type"] = "string";
text["description"] = u8"文本type 用,仅 ASCII非 ASCII 请走 remote_clipboard + Ctrl+V";
props["text"] = text;
Json::Value mods(Json::objectValue);
mods["type"] = "array";
Json::Value modItem(Json::objectValue);
modItem["type"] = "string";
mods["items"] = modItem;
mods["description"] = u8"修饰键可选CTRL/ALT/SHIFT/WIN";
props["modifiers"] = mods;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("session_id");
required.append("action");
schema["required"] = required;
return schema;
}
Json::Value BuildRemoteKeyboardOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
return schema;
}
// tools/callremote_keyboard键盘注入成功返回空对象
std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
CMcpServer& mcp = CMcpServer::Instance();
mcp.SweepIdleScreenCtrl(kScreenCtrlIdleTimeoutSec);
uint64_t devId = 0;
context* ctx = nullptr;
std::string errJson;
if (!ResolveScreenCtrlSessionHost(id, args, parent, devId, ctx, errJson))
return errJson;
std::string sessionId = GetStringArg(args, "session_id");
if (sessionId.empty())
return BuildError(id, -32602, "Missing required parameter: session_id");
std::string action = GetStringArg(args, "action");
if (action.empty())
return BuildError(id, -32602, "Missing required parameter: action");
std::string keyName = GetStringArg(args, "key");
std::string text = GetStringArg(args, "text");
std::vector<int> mods;
{
std::string mErr;
if (!ParseModifiers(args, mods, mErr))
return BuildError(id, -32602, mErr);
}
// 组装注入批次(按发送顺序)
std::vector<MSG64> batch;
if (action == "type") {
if (text.empty())
return BuildError(id, -32602, "Missing required parameter: text");
if (!IsAscii(text))
return BuildError(id, -32602, "text must be ASCII; non-ASCII text requires remote_clipboard + Ctrl+V");
if (text.size() > 1024)
return BuildError(id, -32602, "text too long (max 1024 bytes); use remote_clipboard + Ctrl+V for long text");
BuildTypeBatch(text, batch);
} else if (action == "key_down" || action == "key_up" || action == "key_press") {
if (keyName.empty())
return BuildError(id, -32602, "Missing required parameter: key");
int keyVk = MapKeyNameToVk(keyName);
if (keyVk == 0)
return BuildError(id, -32602, "Unknown key: " + keyName);
bool doDown = (action != "key_up");
bool doUp = (action != "key_down");
if (doDown) {
for (int m : mods) batch.push_back(BuildKeyMsg64(m, true, false));
batch.push_back(BuildKeyMsg64(keyVk, true, false));
}
if (doUp) {
batch.push_back(BuildKeyMsg64(keyVk, false, false));
for (auto it = mods.rbegin(); it != mods.rend(); ++it)
batch.push_back(BuildKeyMsg64(*it, false, false));
}
} else {
return BuildError(id, -32602, "Unknown action: " + action);
}
if (batch.empty())
return BuildError(id, -32602, "No key events generated");
// 校验 session_id/busy 并置 busy注入期间 close/sweep/断线不擦会话,镜像终端 busy 模式)。
context* subCtx = nullptr;
int screenW = 0, screenH = 0;
int r = mcp.BeginScreenCtrlAction(devId, sessionId, subCtx, screenW, screenH);
if (r == 2) return BuildError(id, -32003, "Device busy: another injection is in flight for this session");
if (r == 1) return BuildError(id, -32002, "Remote control session not found, not ready, or closed: " + sessionId);
// 组包 [COMMAND_SCREEN_CONTROL][MSG64*N],经屏幕子连接发送(主连接无效,设计 P2
const int len = (int)(1 + batch.size() * sizeof(MSG64));
std::vector<BYTE> packet((size_t)len);
packet[0] = COMMAND_SCREEN_CONTROL;
memcpy(packet.data() + 1, batch.data(), batch.size() * sizeof(MSG64));
bool ok = subCtx->Send2Client(packet.data(), (ULONG)len) != FALSE;
mcp.EndScreenCtrlAction(devId, sessionId);
if (!ok)
return BuildError(id, -32004, "Failed to send keyboard injection");
// 审计(不可关闭)
if (parent) {
std::string audit = "host " + std::to_string(devId) + " remote-keyboard: session " + sessionId
+ " action=" + action;
if (action == "type") audit += " text=" + text;
else audit += " key=" + keyName;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(audit, 936).c_str()),
(LPARAM)new CString(_TR("MCP远程控制")));
}
Json::Value result(Json::objectValue);
result["structuredContent"] = Json::Value(Json::objectValue);
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"ok");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call 分派
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
const Json::Value& id = root["id"];
@@ -2875,6 +3143,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "terminal_close") return BuildTerminalClose(id, args, parent);
if (toolName == "remote_open") return BuildRemoteOpen(id, args, parent);
if (toolName == "remote_close") return BuildRemoteClose(id, args, parent);
if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent);
return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));