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" ;
61import * as crypto from "crypto" ;
7- import * as https from "https" ;
2+ import * as fs from "fs" ;
3+ import * as fsPromises from "fs/promises" ;
84import * as http from "http" ;
9- import { URL } from "url " ;
5+ import * as https from "https " ;
106import * 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
1212import * as dns from "dns" ;
13- import { getCloudBaseManager } from '../cloudbase-manager.js'
1413import { 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
3076function 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+ // 下载文件到临时目录(保持向后兼容)
178297function downloadFile ( url : string ) : Promise < {
179298 filePath : string ;
180299 contentType : string ;
@@ -244,12 +363,12 @@ function downloadFile(url: string): Promise<{
244363}
245364
246365export 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}
0 commit comments