Skip to content

Commit 47a2fd2

Browse files
committed
feat(download): add downloadRemoteFileToPath tool with relative path support 🚀
- Add new downloadRemoteFileToPath tool for downloading files to project directory - Support relative path with automatic project root detection - Add path safety validation to prevent directory traversal attacks - Maintain backward compatibility with existing downloadRemoteFile tool - Add comprehensive integration tests for new functionality - Update setup.ts IDE filtering to use filtered directory structure
1 parent e5e744b commit 47a2fd2

File tree

3 files changed

+376
-20
lines changed

3 files changed

+376
-20
lines changed

mcp/src/tools/download.ts

Lines changed: 216 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import { z } from "zod";
2-
import * as fsPromises from "fs/promises";
3-
import * as fs from "fs";
4-
import * as path from "path";
5-
import * as os from "os";
61
import * as crypto from "crypto";
7-
import * as https from "https";
2+
import * as fs from "fs";
3+
import * as fsPromises from "fs/promises";
84
import * as http from "http";
9-
import { URL } from "url";
5+
import * as https from "https";
106
import * as net from "net";
7+
import * as os from "os";
8+
import * as path from "path";
9+
import { URL } from "url";
10+
import { z } from "zod";
1111

1212
import * as dns from "dns";
13-
import { getCloudBaseManager } from '../cloudbase-manager.js'
1413
import { ExtendedMcpServer } from '../server.js';
1514

1615
// 常量定义
@@ -26,6 +25,53 @@ const ALLOWED_CONTENT_TYPES = [
2625
"application/x-zip-compressed"
2726
];
2827

28+
// 获取项目根目录
29+
function getProjectRoot(): string {
30+
// 优先级:环境变量 > 当前工作目录
31+
return process.env.WORKSPACE_FOLDER_PATHS ||
32+
process.env.PROJECT_ROOT ||
33+
process.env.GITHUB_WORKSPACE ||
34+
process.env.CI_PROJECT_DIR ||
35+
process.env.BUILD_SOURCESDIRECTORY ||
36+
process.cwd();
37+
}
38+
39+
// 验证相对路径是否安全(不允许路径遍历)
40+
function isPathSafe(relativePath: string): boolean {
41+
// 检查是否包含路径遍历操作
42+
if (relativePath.includes('..') ||
43+
relativePath.includes('~') ||
44+
path.isAbsolute(relativePath)) {
45+
return false;
46+
}
47+
48+
// 检查路径是否规范化后仍然安全
49+
const normalizedPath = path.normalize(relativePath);
50+
if (normalizedPath.startsWith('..') ||
51+
normalizedPath.startsWith('/') ||
52+
normalizedPath.startsWith('\\')) {
53+
return false;
54+
}
55+
56+
return true;
57+
}
58+
59+
// 计算最终下载路径
60+
function calculateDownloadPath(relativePath: string): string {
61+
const projectRoot = getProjectRoot();
62+
const finalPath = path.join(projectRoot, relativePath);
63+
64+
// 确保最终路径在项目根目录内
65+
const normalizedProjectRoot = path.resolve(projectRoot);
66+
const normalizedFinalPath = path.resolve(finalPath);
67+
68+
if (!normalizedFinalPath.startsWith(normalizedProjectRoot)) {
69+
throw new Error('相对路径超出项目根目录范围');
70+
}
71+
72+
return finalPath;
73+
}
74+
2975
// 检查是否为内网 IP
3076
function isPrivateIP(ip: string): boolean {
3177
// 如果不是有效的 IP 地址,返回 true(保守处理)
@@ -174,7 +220,80 @@ async function isUrlAndContentTypeSafe(url: string, contentType: string): Promis
174220
}
175221
}
176222

177-
// 下载文件
223+
// 下载文件到指定路径
224+
function downloadFileToPath(url: string, targetPath: string): Promise<{
225+
filePath: string;
226+
contentType: string;
227+
fileSize: number;
228+
}> {
229+
return new Promise((resolve, reject) => {
230+
const client = url.startsWith('https:') ? https : http;
231+
232+
client.get(url, async (res) => {
233+
if (res.statusCode !== 200) {
234+
reject(new Error(`HTTP Error: ${res.statusCode}`));
235+
return;
236+
}
237+
238+
const contentType = res.headers['content-type'] || '';
239+
const contentLength = parseInt(res.headers['content-length'] || '0', 10);
240+
const contentDisposition = res.headers['content-disposition'];
241+
242+
// 安全检查
243+
if (!await isUrlAndContentTypeSafe(url, contentType)) {
244+
reject(new Error('不安全的 URL 或内容类型,或者目标为内网地址'));
245+
return;
246+
}
247+
248+
// 文件大小检查
249+
if (contentLength > MAX_FILE_SIZE) {
250+
reject(new Error(`文件大小 ${contentLength} 字节超过 ${MAX_FILE_SIZE} 字节限制`));
251+
return;
252+
}
253+
254+
// 确保目标目录存在
255+
const targetDir = path.dirname(targetPath);
256+
try {
257+
await fsPromises.mkdir(targetDir, { recursive: true });
258+
} catch (error) {
259+
reject(new Error(`无法创建目标目录: ${error instanceof Error ? error.message : '未知错误'}`));
260+
return;
261+
}
262+
263+
// 创建写入流
264+
const fileStream = fs.createWriteStream(targetPath);
265+
let downloadedSize = 0;
266+
267+
res.on('data', (chunk) => {
268+
downloadedSize += chunk.length;
269+
if (downloadedSize > MAX_FILE_SIZE) {
270+
fileStream.destroy();
271+
fsPromises.unlink(targetPath).catch(() => {});
272+
reject(new Error(`文件大小超过 ${MAX_FILE_SIZE} 字节限制`));
273+
}
274+
});
275+
276+
res.pipe(fileStream);
277+
278+
fileStream.on('finish', () => {
279+
resolve({
280+
filePath: targetPath,
281+
contentType,
282+
fileSize: downloadedSize
283+
});
284+
});
285+
286+
fileStream.on('error', (error: NodeJS.ErrnoException) => {
287+
fsPromises.unlink(targetPath).catch(() => {});
288+
reject(error);
289+
});
290+
}).on('error', (error: NodeJS.ErrnoException) => {
291+
reject(error);
292+
});
293+
});
294+
}
295+
296+
// 下载文件到临时目录(保持向后兼容)
178297
function downloadFile(url: string): Promise<{
179298
filePath: string;
180299
contentType: string;
@@ -244,12 +363,12 @@ function downloadFile(url: string): Promise<{
244363
}
245364

246365
export function registerDownloadTools(server: ExtendedMcpServer) {
247-
// downloadRemoteFile - 下载远程文件 (cloud-incompatible)
366+
// downloadRemoteFile - 下载远程文件到临时目录 (cloud-incompatible)
248367
server.registerTool(
249368
"downloadRemoteFile",
250369
{
251-
title: "下载远程文件",
252-
description: "下载远程文件到本地临时文件,返回一个系统的绝对路径",
370+
title: "下载远程文件到临时目录",
371+
description: "下载远程文件到本地临时文件,返回一个系统的绝对路径。适用于需要临时处理文件的场景。",
253372
inputSchema: {
254373
url: z.string().describe("远程文件的 URL 地址")
255374
},
@@ -274,7 +393,8 @@ export function registerDownloadTools(server: ExtendedMcpServer) {
274393
filePath: result.filePath,
275394
contentType: result.contentType,
276395
fileSize: result.fileSize,
277-
message: "文件下载成功"
396+
message: "文件下载成功到临时目录",
397+
note: "文件保存在临时目录中,请注意及时处理"
278398
}, null, 2)
279399
}
280400
]
@@ -295,4 +415,87 @@ export function registerDownloadTools(server: ExtendedMcpServer) {
295415
}
296416
}
297417
);
418+
419+
// downloadRemoteFileToPath - 下载远程文件到指定路径 (cloud-incompatible)
420+
server.registerTool(
421+
"downloadRemoteFileToPath",
422+
{
423+
title: "下载远程文件到指定路径",
424+
description: "下载远程文件到项目根目录下的指定相对路径。例如:小程序的 Tabbar 等素材图片,必须使用 **png** 格式,可以从 Unsplash、wikimedia【一般选用 500 大小即可、Pexels、Apple 官方 UI 等资源中选择来下载。",
425+
inputSchema: {
426+
url: z.string().describe("远程文件的 URL 地址"),
427+
relativePath: z.string().describe("相对于项目根目录的路径,例如:'assets/images/logo.png' 或 'docs/api.md'。不允许使用 ../ 等路径遍历操作。")
428+
},
429+
annotations: {
430+
readOnlyHint: false,
431+
destructiveHint: false,
432+
idempotentHint: false,
433+
openWorldHint: true,
434+
category: "download"
435+
}
436+
},
437+
async ({ url, relativePath }: { url: string; relativePath: string }) => {
438+
try {
439+
// 验证相对路径安全性
440+
if (!isPathSafe(relativePath)) {
441+
return {
442+
content: [
443+
{
444+
type: "text",
445+
text: JSON.stringify({
446+
success: false,
447+
error: "不安全的相对路径",
448+
message: "相对路径包含路径遍历操作(../)或绝对路径,出于安全考虑已拒绝",
449+
suggestion: "请使用项目根目录下的相对路径,例如:'assets/images/logo.png'"
450+
}, null, 2)
451+
}
452+
]
453+
};
454+
}
455+
456+
// 计算最终下载路径
457+
const targetPath = calculateDownloadPath(relativePath);
458+
const projectRoot = getProjectRoot();
459+
460+
console.log(`📁 项目根目录: ${projectRoot}`);
461+
console.log(`📁 相对路径: ${relativePath}`);
462+
console.log(`📁 最终路径: ${targetPath}`);
463+
464+
// 下载文件到指定路径
465+
const result = await downloadFileToPath(url, targetPath);
466+
467+
return {
468+
content: [
469+
{
470+
type: "text",
471+
text: JSON.stringify({
472+
success: true,
473+
filePath: result.filePath,
474+
relativePath: relativePath,
475+
contentType: result.contentType,
476+
fileSize: result.fileSize,
477+
projectRoot: projectRoot,
478+
message: "文件下载成功到指定路径",
479+
note: `文件已保存到项目目录: ${relativePath}`
480+
}, null, 2)
481+
}
482+
]
483+
};
484+
} catch (error: any) {
485+
return {
486+
content: [
487+
{
488+
type: "text",
489+
text: JSON.stringify({
490+
success: false,
491+
error: error.message,
492+
message: "文件下载失败",
493+
suggestion: "请检查相对路径是否正确,确保不包含 ../ 等路径遍历操作"
494+
}, null, 2)
495+
}
496+
]
497+
};
498+
}
499+
}
500+
);
298501
}

mcp/src/tools/setup.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { z } from "zod";
1+
import AdmZip from "adm-zip";
22
import * as fs from "fs";
33
import * as fsPromises from "fs/promises";
4-
import * as path from "path";
5-
import * as os from "os";
6-
import * as https from "https";
74
import * as http from "http";
8-
import { execSync } from "child_process";
9-
import AdmZip from "adm-zip";
5+
import * as https from "https";
6+
import * as os from "os";
7+
import * as path from "path";
8+
import { z } from "zod";
109
import { ExtendedMcpServer } from '../server.js';
1110

1211

@@ -409,7 +408,7 @@ export function registerSetupTools(server: ExtendedMcpServer) {
409408
const workingDir = await createFilteredDirectory(extractDir, filteredFiles, ide);
410409

411410
// 检查是否需要复制到项目目录
412-
const workspaceFolder = process.env.WORKSPACE_FOLDER_PATHS;
411+
const workspaceFolder = process.env.WORKSPACE_FOLDER_PATHS || process.cwd();
413412
let finalFiles: string[] = [];
414413
let createdCount = 0;
415414
let overwrittenCount = 0;

0 commit comments

Comments
 (0)