Feature: Add upload_file MCP tool (V2 protocol, main-connection upload)

Implement the P2 upload_file tool to push a local file or directory from the
master to an online Windows host over the existing V2 file-transfer protocol.
The server drives FileBatchTransferWorkerV2 synchronously on the main
connection through a headless callback, reuses the list_files chain for the
overwrite pre-check, and is gated by McpFileTransfer=1 plus McpReadonly=0.

The client main connection never initialized the file-transfer module, so
g_status stayed 0 and RecvFileChunkV2 silently dropped every chunk, truncating
uploads to zero bytes. Add a once-per-process lazy InitFileUpload in the
COMMAND_SEND_FILE_V2 handler that mirrors the FileManager init; the destructor
deliberately does not Uninit so g_status remains 1 across reconnects.

Update the design doc to record the client-side change and correct the
upload_file description to state that sha256 is not returned (V2 has no
receiver-to-sender ACK; integrity is checked client-side and logged only).

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-31 22:43:16 +02:00
parent 5c77f5ce14
commit 6bcd593b6d
4 changed files with 531 additions and 12 deletions

View File

@@ -1158,6 +1158,9 @@ std::string BuildRemoteMouse(const Json::Value& id, const Json::Value& args, CMy
Json::Value BuildDownloadFileInputSchema();
Json::Value BuildDownloadFileOutputSchema();
std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
Json::Value BuildUploadFileInputSchema();
Json::Value BuildUploadFileOutputSchema();
std::string BuildUploadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent);
// tools/list
@@ -1419,6 +1422,17 @@ std::string BuildToolsListResult(const Json::Value& id) {
tools.append(tool);
}
// 17) upload_fileP2MCP 文件上传,仅 Windows安全门McpFileTransfer=1 且 McpReadonly=0
// 写远程盘,故额外要求关闭只读(与 terminal_* / remote_* 一致)。
if (CMcpServer::Instance().IsFileTransferEnabled() && !CMcpServer::Instance().IsReadonly()) {
Json::Value tool(Json::objectValue);
tool["name"] = "upload_file";
tool["description"] = u8"把主控本机文件或目录上传到指定在线 Windows 主机的目录V2 协议主连接发送。local_path 为主控本机绝对路径文件或目录目录递归上传remote_dir 为远程保存目录不存在自动创建overwrite=false 时跳过 remote_dir 下已存在的同名顶层项。返回 files[{path,size,sha256}] 与 total_bytes/skippedsha256 恒为空V2 无接收方回执,完整性由客户端本地校验、失败仅记日志)。";
tool["inputSchema"] = BuildUploadFileInputSchema();
tool["outputSchema"] = BuildUploadFileOutputSchema();
tools.append(tool);
}
result["tools"] = tools;
return BuildResult(id, result);
}
@@ -3796,6 +3810,371 @@ std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CM
return BuildError(id, -32001, msg);
}
// ===== P2upload_file 辅助 =====
// ASCII 小写GBK 双字节首字节 0x81..0xFE 不含 A-Z安全Windows 路径名不区分大小写。
static std::string AsciiLower(std::string s) {
for (char& c : s) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
return s;
}
// 词法检查路径是否含 ".." 段(防上传目标逃逸到预期目录之外)。
static bool HasDotDotSegment(const std::string& path) {
size_t i = 0;
while (i < path.size()) {
size_t j = path.find_first_of("\\/", i);
if (j == std::string::npos) j = path.size();
size_t n = j - i;
if (n == 2 && path[i] == '.' && path[i + 1] == '.') return true;
i = j + 1;
}
return false;
}
// 递归收集本机目录下的文件与目录项(目录在前、子项随后,供 FileBatchTransferWorkerV2 发送)。
// 与 common/file_upload.cpp 的 ExpandDirectory 同构(该函数未在 file_upload.h 导出,故本地实现)。
static void CollectLocalFiles(const std::string& dir, std::vector<std::string>& out) {
std::string searchPath = dir + "\\*";
WIN32_FIND_DATAA fd;
HANDLE hFind = FindFirstFileA(searchPath.c_str(), &fd);
if (hFind == INVALID_HANDLE_VALUE) return;
do {
if (strcmp(fd.cFileName, ".") == 0 || strcmp(fd.cFileName, "..") == 0) continue;
std::string fullPath = dir + "\\" + fd.cFileName;
out.push_back(fullPath); // 文件与目录都加入
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
CollectLocalFiles(fullPath, out); // 递归(目录项先于其子文件)
} while (FindNextFileA(hFind, &fd));
FindClose(hFind);
}
// upload_file 无头发送回调数据
struct UploadCallbackData {
CMy2015RemoteDlg* parent;
uint64_t clientID;
DWORD deadline = 0; // 整体传输截止GetTickCount 毫秒0=不限)
bool timedOut = false;
bool failed = false; // 任一 Send2Client 失败或客户端离线
};
// upload_file 无头发送回调(镜像 GUI SendFileChunkToClientV2去掉 dlg 进度)。
static bool UploadSendChunkHeadless(void* user, FileChunkPacketV2* chunk, unsigned char* data, int size) {
UploadCallbackData* cb = (UploadCallbackData*)user;
if (!cb || !cb->parent) return false;
if (cb->deadline && (int)(GetTickCount() - cb->deadline) >= 0) { // 整体超时DWORD 回绕安全)
cb->timedOut = true;
cb->failed = true;
return false;
}
context* ctx = cb->parent->FindHost(cb->clientID);
if (!ctx) { cb->failed = true; return false; }
BOOL sent = ctx->Send2Client(data, size);
if (!sent) cb->failed = true;
return sent != FALSE;
}
// 列远程目录一层,返回顶层名集合(小写原始 ANSI客户端 ANSI=936 与主控本地名同编码)。
// 复用 list_files 的 COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST→COMMAND_LIST_FILES→TOKEN_FILE_LIST 链路。
static bool ListRemoteTopLevelNames(CMy2015RemoteDlg* parent, uint64_t devId,
const std::string& remoteDirAnsi,
std::set<std::string>& names) {
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.BeginPending(devId, "list_files", remoteDirAnsi)) return false;
context* ctx = FindMainContext(parent, devId);
if (!ctx) { mcp.ClearPending(devId); return false; }
BYTE cmd = COMMAND_LIST_DRIVE;
if (!ctx->Send2Client(&cmd, 1)) { mcp.ClearPending(devId); return false; }
std::vector<BYTE> data;
if (!mcp.WaitPending(devId, data, kMcpToolTimeoutMs)) return false;
// TOKEN_FILE_LIST 布局:[token:1][attr:1][name\0][sizeHigh:4][sizeLow:4][ft:8]...
if (data.size() < 2) return true; // 空目录 → 无顶层名
const char* p = (const char*)data.data();
size_t len = data.size();
size_t off = 1; // 跳过 token 字节
while (off + 1 <= len) {
off += 1; // attr
const char* name = p + off;
size_t nlen = BoundedStrlen(name, len - off);
if (nlen == 0 || nlen >= len - off) break; // 空记录/越界 = 尾部零填充
off += nlen + 1;
if (off + 16 > len) break;
off += 16; // sizeHigh/sizeLow/ft(8)
names.insert(AsciiLower(std::string(name, nlen)));
}
return true;
}
Json::Value BuildUploadFileInputSchema() {
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 lp(Json::objectValue);
lp["type"] = "string";
lp["description"] = u8"主控本机文件或目录的绝对路径(目录递归上传),如 C:\\Users\\foo\\Pictures";
props["local_path"] = lp;
Json::Value rd(Json::objectValue);
rd["type"] = "string";
rd["description"] = u8"远程主机的保存目录(不存在则自动创建),如 C:\\uploads";
props["remote_dir"] = rd;
Json::Value ow(Json::objectValue);
ow["type"] = "boolean";
ow["description"] = u8"是否覆盖 remote_dir 下已存在的同名顶层文件/目录;默认 false跳过并计入 skipped";
props["overwrite"] = ow;
Json::Value to(Json::objectValue);
to["type"] = "integer";
to["description"] = u8"超时毫秒(默认 600000=10 分钟,上限 3600000=1 小时)";
props["timeout_ms"] = to;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("local_path");
required.append("remote_dir");
schema["required"] = required;
return schema;
}
Json::Value BuildUploadFileOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value filesProp(Json::objectValue);
filesProp["type"] = "array";
Json::Value items(Json::objectValue);
items["type"] = "object";
Json::Value itemProps(Json::objectValue);
Json::Value pathProp(Json::objectValue); pathProp["type"] = "string"; itemProps["path"] = pathProp;
Json::Value sizeProp(Json::objectValue); sizeProp["type"] = "integer"; itemProps["size"] = sizeProp;
Json::Value shaProp(Json::objectValue); shaProp["type"] = "string"; itemProps["sha256"] = shaProp;
items["properties"] = itemProps;
filesProp["items"] = items;
props["files"] = filesProp;
Json::Value tb(Json::objectValue); tb["type"] = "integer"; props["total_bytes"] = tb;
Json::Value sk(Json::objectValue); sk["type"] = "integer"; props["skipped"] = sk;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
return schema;
}
// tools/callupload_file上传主控本机文件/目录到远程主机V2 协议,主连接发送)
std::string BuildUploadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
// 分派门控McpFileTransfer=1 且 McpReadonly=0即便绕过 tools/list 直调也拒绝。
if (!CMcpServer::Instance().IsFileTransferEnabled())
return BuildError(id, -32006, "File transfer is disabled: requires McpFileTransfer=1");
if (CMcpServer::Instance().IsReadonly())
return BuildError(id, -32006, "upload_file is disabled in read-only mode: requires McpReadonly=0");
uint64_t devId = 0;
std::string err;
if (!ParseHostIdArg(args, devId, err))
return BuildError(id, -32602, err);
context* ctx = FindMainContext(parent, devId);
if (!ctx)
return BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId));
// 仅 Windows 客户端实现 V2 主连接接收链路(与 download_file 一致)。
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
if (clientType == "LNX" || clientType == "MAC")
return BuildError(id, -32005, "upload_file is only supported on Windows hosts");
if (!ctx->SupportsFileV2())
return BuildError(id, -32006, "Host does not support V2 file transfer");
std::string localPathUtf8 = GetStringArg(args, "local_path");
if (localPathUtf8.empty())
return BuildError(id, -32602, "Missing required parameter: local_path");
std::string remoteDirUtf8 = GetStringArg(args, "remote_dir");
if (remoteDirUtf8.empty())
return BuildError(id, -32602, "Missing required parameter: remote_dir");
bool overwrite = false;
if (args.isMember("overwrite") && args["overwrite"].isBool())
overwrite = args["overwrite"].asBool();
int timeoutMs = 600000;
if (args.isMember("timeout_ms")) {
int v = 0;
if (!GetIntArg(args, "timeout_ms", v) || v <= 0 || v > 3600000)
return BuildError(id, -32602, "timeout_ms must be in range 1..3600000");
timeoutMs = v;
}
// 主控本机路径UTF-8 → 主控 ANSI去尾斜杠。
std::string localPathAnsi = ToAnsi(localPathUtf8, CP_ACP);
if (localPathAnsi.empty())
return BuildError(id, -32602, "Invalid local_path encoding");
while (!localPathAnsi.empty() && (localPathAnsi.back() == '\\' || localPathAnsi.back() == '/'))
localPathAnsi.pop_back();
DWORD attr = GetFileAttributesA(localPathAnsi.c_str());
if (attr == INVALID_FILE_ATTRIBUTES)
return BuildError(id, -32602, "local_path not found: " + localPathUtf8);
// 收集本机文件 + 目录项(目录在前、子项随后),镜像 ExpandDirectories 的顺序语义。
std::vector<std::string> files;
files.push_back(localPathAnsi);
if (attr & FILE_ATTRIBUTE_DIRECTORY)
CollectLocalFiles(localPathAnsi, files);
// 远程保存目录UTF-8 → 客户端 ANSI(936),去尾斜杠 + 结尾 '\'(作 targetDir 前缀)。
std::string remoteDirAnsi = ToAnsi(remoteDirUtf8, 936);
if (remoteDirAnsi.empty())
return BuildError(id, -32602, "Invalid remote_dir encoding");
while (!remoteDirAnsi.empty() && (remoteDirAnsi.back() == '\\' || remoteDirAnsi.back() == '/'))
remoteDirAnsi.pop_back();
if (remoteDirAnsi.empty())
return BuildError(id, -32602, "Invalid remote_dir: " + remoteDirUtf8);
if (HasDotDotSegment(remoteDirAnsi))
return BuildError(id, -32602, "remote_dir must not contain '..' path segments");
std::string targetDir = remoteDirAnsi + "\\";
// overwrite=false预检 remote_dir 一层,跳过已存在的同名顶层项(单文件精确、目录整体跳过)。
int skipped = 0;
std::vector<std::string> sendFiles;
if (!overwrite) {
std::set<std::string> existing;
if (!ListRemoteTopLevelNames(parent, devId, remoteDirAnsi, existing))
return BuildError(id, -32001, "Failed to list remote_dir for overwrite check");
std::string rootDir = GetCommonRoot(files);
for (const std::string& f : files) {
DWORD a = GetFileAttributesA(f.c_str());
bool isDir = (a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY));
std::string rel = GetRelativePath(rootDir, f);
size_t sep = rel.find_first_of("\\/");
std::string top = (sep == std::string::npos) ? rel : rel.substr(0, sep);
if (existing.count(AsciiLower(top))) {
if (!isDir) skipped++; // 目录项不计入 skipped与 download 一致)
continue;
}
sendFiles.push_back(f);
}
} else {
sendFiles = files;
}
// 全部被跳过:直接返回成功(不发起传输)。
if (sendFiles.empty()) {
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["files"] = Json::Value(Json::arrayValue);
structuredContent["total_bytes"] = (Json::UInt64)0;
structuredContent["skipped"] = skipped;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"上传完成:所有文件均已存在,跳过 ") + std::to_string(skipped) + u8" 个。";
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.BeginFileUpload(devId))
return BuildError(id, -32003, "Device busy: another request is pending for this host");
TransferOptionsV2 opts;
opts.transferID = GenerateTransferID();
opts.srcClientID = 0; // 主控端
opts.dstClientID = devId;
opts.enableResume = false;
std::string hash = GetPwdHash();
std::string hmac = GetHMAC(100);
UploadCallbackData cbData;
cbData.parent = parent;
cbData.clientID = devId;
cbData.deadline = GetTickCount() + (DWORD)timeoutMs; // DWORD 回绕由回调的 (int) 差值判断吸收
// 同步驱动发送(走主连接);无外来流,无需 WaitFileTransferDone / 收包计数。
int result = FileBatchTransferWorkerV2(sendFiles, targetDir, &cbData,
UploadSendChunkHeadless, nullptr, hash, hmac, opts);
bool clientOnline = (FindMainContext(parent, devId) != nullptr);
mcp.ClearFileTransfer(devId);
bool ok = (result == 0) && !cbData.failed && clientOnline;
// 计算已发送文件(远程完整路径 = targetDir + relPath与字节数目录项不计入 files。
std::string outRoot = GetCommonRoot(sendFiles);
std::vector<FileTransferEntry> outFiles;
uint64_t totalBytes = 0;
for (const std::string& f : sendFiles) {
DWORD a = GetFileAttributesA(f.c_str());
if (a == INVALID_FILE_ATTRIBUTES || (a & FILE_ATTRIBUTE_DIRECTORY)) continue;
std::string rel = GetRelativePath(outRoot, f);
FileTransferEntry e;
e.path = targetDir + rel;
WIN32_FILE_ATTRIBUTE_DATA fad;
if (GetFileAttributesExA(f.c_str(), GetFileExInfoStandard, &fad))
e.size = ((uint64_t)fad.nFileSizeHigh << 32) | fad.nFileSizeLow;
// §7.5V2 无接收方→发送方 ACK发送方自算 SHA-256 未由 worker 回传P2 留空(延后 P3
e.sha256 = "";
totalBytes += e.size;
outFiles.push_back(std::move(e));
}
if (ok) {
if (parent) {
std::string audit = "host " + std::to_string(devId) + " upload_file: "
+ localPathUtf8 + " -> " + remoteDirUtf8
+ " (" + std::to_string(outFiles.size()) + " files, "
+ std::to_string(totalBytes) + " bytes, skipped " + std::to_string(skipped) + ")";
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(audit, 936).c_str()),
(LPARAM)new CString(_TR("MCP文件传输")));
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
Json::Value filesArr(Json::arrayValue);
for (auto& f : outFiles) {
Json::Value fo(Json::objectValue);
fo["path"] = ToUtf8(f.path.c_str(), 936);
fo["size"] = (Json::UInt64)f.size;
fo["sha256"] = f.sha256;
filesArr.append(fo);
}
structuredContent["files"] = filesArr;
structuredContent["total_bytes"] = (Json::UInt64)totalBytes;
structuredContent["skipped"] = skipped;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"上传完成:") + std::to_string(outFiles.size()) + u8" 个文件("
+ std::to_string(totalBytes) + u8" 字节),跳过 " + std::to_string(skipped) + u8" 个。";
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
if (parent) {
std::string audit = "host " + std::to_string(devId) + " upload_file FAILED: "
+ localPathUtf8 + " -> " + remoteDirUtf8;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(audit, 936).c_str()),
(LPARAM)new CString(_TR("MCP文件传输")));
}
const char* msg = cbData.timedOut ? "Timeout waiting for upload to complete"
: "Upload failed (target offline or send error)";
return BuildError(id, -32001, msg);
}
// tools/call 分派
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
const Json::Value& id = root["id"];
@@ -3830,6 +4209,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent);
if (toolName == "remote_clipboard") return BuildRemoteClipboard(id, args, parent);
if (toolName == "download_file") return BuildDownloadFile(id, args, parent);
if (toolName == "upload_file") return BuildUploadFile(id, args, parent);
return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
@@ -4569,6 +4949,21 @@ bool CMcpServer::BeginFileTransferPending(uint64_t device_id, const std::string&
return true;
}
bool CMcpServer::BeginFileUpload(uint64_t device_id) {
// 与一次性挂起请求互斥F2先查对方注册表再查自己的避免跨锁嵌套死锁。
{
std::lock_guard<std::mutex> lk(m_PendingMutex);
if (m_Pending.find(device_id) != m_Pending.end()) return false;
}
std::lock_guard<std::mutex> lk(m_FileXferMutex);
if (m_FileXferSessions.find(device_id) != m_FileXferSessions.end()) return false; // 单设备单传输
FileTransferSession s;
s.tool = "upload_file";
s.startAt = time(nullptr);
m_FileXferSessions[device_id] = std::move(s);
return true;
}
bool CMcpServer::OnDownloadDriveList(uint64_t device_id, context* fmSubCtx) {
std::string localDir, remotePath;
{