Skip to main content
Glama

360 AI Cloud Drive MCP Server

by Qihoo360
file-download-stdio.ts15.9 kB
import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getAuthInfo, AuthInfo } from "../utils/auth.js"; import { getConfig, TOOL_LIMIT_NOTE } from "../utils/const.js"; import { gethttpContext } from "../utils/transport.js"; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { spawn } from 'child_process'; import { homedir } from 'os'; import fs from 'fs/promises'; const execAsync = promisify(exec); // 添加一个类型来跟踪下载状态 type DownloadProgress = { status: 'downloading' | 'completed' | 'failed'; progress?: string; message: string; }; // 调用云盘API获取下载链接 async function getDownloadUrl(authInfo: AuthInfo, nid: string): Promise<any> { try { const url = new URL(authInfo.request_url || ''); // 构建请求头 const headers = { 'Access-Token': authInfo.access_token || '', 'User-Agent': 'yunpan_mcp_server' }; // 构建请求参数 const baseParams: Record<string, string> = { 'method': 'Sync.getVerifiedDownLoadUrl', 'access_token': authInfo.access_token || '', 'qid': authInfo.qid || '', 'sign': authInfo.sign || '', 'nid': String(nid) }; // 添加所有参数到URL Object.entries(baseParams).forEach(([key, value]) => { url.searchParams.append(key, String(value)); }); const response = await fetch(url.toString(), { method: 'GET', headers: headers }); if (!response.ok) { throw new Error(`API 请求失败,状态码: ${response.status}`); } // 获取原始响应文本 const responseText = await response.text(); try { // 尝试解析为JSON const data = JSON.parse(responseText); return data; } catch (jsonError) { throw new Error(`无法解析API响应: ${responseText.substring(0, 100)}...`); } } catch (error) { throw error; } } // 检查目录权限并创建目录(如果不存在) async function checkAndCreateDirectory(dirPath: string): Promise<boolean> { try { // 检查目录是否存在 try { await fs.access(dirPath, fs.constants.F_OK); } catch (error) { // 目录不存在,尝试创建 await fs.mkdir(dirPath, { recursive: true }); } // 检查读写权限 await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK); return true; } catch (error) { console.error(`目录 ${dirPath} 权限检查失败:`, error); return false; } } // 从URL中提取fname和fsize参数 function extractFileInfoFromUrl(url: string): {filename: string, sizeMB: number} { try { const urlObj = new URL(url); // 优先从URL参数中获取fname const fnameParam = urlObj.searchParams.get('fname'); const filename = fnameParam ? decodeURIComponent(fnameParam) : path.basename(urlObj.pathname) || 'downloaded_file'; // 从URL参数中获取fsize const fsizeParam = urlObj.searchParams.get('fsize'); const sizeBytes = fsizeParam ? parseInt(fsizeParam, 10) : 0; const sizeMB = sizeBytes / (1024 * 1024); return { filename, sizeMB }; } catch (error) { console.error('提取文件信息失败:', error); return { filename: 'downloaded_file', sizeMB: 0 }; } } // 修改下载函数实现,返回进度信息而不是使用回调 async function downloadFileWithCurl(url: string, filename: string, downloadDir: string, timeoutMs: number = 300000): Promise<{ downloadPath: string; progressLog: string[] }> { try { // 确保下载目录存在并有权限 const hasPermission = await checkAndCreateDirectory(downloadDir); if (!hasPermission) { throw new Error(`目录 ${downloadDir} 没有读写权限`); } const downloadPath = path.join(downloadDir, filename); const progressLog: string[] = []; let lastReportedPercentage = -1; // Use -1 to ensure 0% can be reported if seen let lineBuffer = ''; return new Promise((resolve, reject) => { const curlArgs = [ '-L', // Follow redirects '-#', // Show progress bar (output to stderr) '-A', // User-Agent 'yunpan_mcp_server', url, '-o', // Output to file downloadPath ]; // 添加 Windows 特定的 SSL/TLS 参数 if (process.platform === 'win32') { curlArgs.push('--ssl-reqd'); // 要求 SSL/TLS curlArgs.push('--tlsv1.2'); // 强制使用 TLS 1.2 // curlArgs.push('--insecure'); // 如果需要可以跳过证书验证(临时方案) } const curl = spawn('curl', curlArgs); curl.stderr.on('data', (data) => { const dataStr = data.toString(); lineBuffer += dataStr; let splitParts = lineBuffer.split(/[\r\n]/); // Split by CR or LF if (splitParts.length > 1) { // If we have at least one full line terminated by CR/LF for (let i = 0; i < splitParts.length - 1; i++) { const line = splitParts[i].trim(); if (line.length === 0) continue; const percentageMatch = line.match(/(\d{1,3}(\.\d+)?)\s*%/); if (percentageMatch && percentageMatch[1]) { const currentPercentage = Math.floor(parseFloat(percentageMatch[1])); if ((currentPercentage >= lastReportedPercentage + 10 && currentPercentage < 100) || (currentPercentage === 100 && lastReportedPercentage < 100)) { progressLog.push(`进度: ${currentPercentage}%`); lastReportedPercentage = currentPercentage; } } } lineBuffer = splitParts[splitParts.length - 1]; // Keep the last (potentially incomplete) part } }); curl.on('close', (code) => { const finalLine = lineBuffer.trim(); if (finalLine.length > 0) { const percentageMatch = finalLine.match(/(\d{1,3}(\.\d+)?)\s*%/); if (percentageMatch && percentageMatch[1]) { const currentPercentage = Math.floor(parseFloat(percentageMatch[1])); if (currentPercentage === 100 && lastReportedPercentage < 100) { progressLog.push(`进度: 100%`); lastReportedPercentage = currentPercentage; } } } if (code === 0 && lastReportedPercentage < 100) { const foundHundred = progressLog.some(p => p.includes("100%")); if (!foundHundred) { progressLog.push(`进度: 100%`); } } if (code === 0) { resolve({ downloadPath, progressLog }); } else { let errorDetail = `下载失败,curl 退出码: ${code}.`; if (progressLog.length > 0) { errorDetail += ` Curl 输出摘要: ${progressLog.join('; ')}`; } if (lineBuffer.trim().length > 0 && !progressLog.some(l => l.includes(lineBuffer.trim()))) { errorDetail += ` Curl stderr tail: ${lineBuffer.trim()}`; } reject(new Error(errorDetail)); } }); curl.on('error', (spawnError) => { clearTimeout(timeoutId); // Clear timeout on spawn error too reject(new Error(`执行下载命令时出错: ${spawnError.message}`)); }); const timeoutId = setTimeout(() => { if (!curl.killed) { curl.kill(); } reject(new Error(`下载超时(${timeoutMs / 60000}分钟)`)); }, timeoutMs); }); } catch (error: any) { throw error; } } // 后台下载文件,不等待完成 async function downloadFileInBackground(url: string, filename: string, downloadDir: string, timeoutMs: number = 300000): Promise<void> { try { // 构建后台下载命令 const downloadPath = path.join(downloadDir, filename); let command = `curl -L -A "yunpan_mcp_server" "${url}" -o "${downloadPath}"`; if (process.platform === 'win32') { command += ' --ssl-reqd --tlsv1.2'; // 添加 Windows 特定的 SSL/TLS 参数 } command += ' &'; // 执行后台下载命令 exec(command, (error, stdout, stderr) => { if (error) { console.error(`后台下载失败: ${error.message}`); return; } console.error(`后台下载已启动: ${filename}`); }); } catch (error: any) { console.error(`启动后台下载失败: ${error.message}`); } } export function registerFileDownloadStdioTool(server: McpServer) { server.tool( "file-download-stdio", "获取云盘中指定文件的下载链接并支持直接下载文件。可以指定下载目录,默认下载到用户主目录的 .mcp-downloads 文件夹中。", { nid: z.string().describe("文件的唯一标识ID,可通过文件列表或搜索获取"), auto: z.boolean().optional().describe("是否直接下载文件,默认为 true"), downloadDir: z.string().optional().describe("指定下载目录,必须有读写权限,默认为用户主目录下的 .mcp-downloads 文件夹"), }, async ({ nid, auto = true, downloadDir: userDownloadDir }, mcpReq: any) => { // 设置下载目录,如果用户未指定则使用默认目录 const downloadDir = userDownloadDir || path.join(homedir(), '.mcp-downloads'); const httpContext = gethttpContext(mcpReq, server); // 使用transport中的authInfo const transportAuthInfo = httpContext.authInfo; try { let authInfo: AuthInfo; const extraParams = { nid: nid }; try { // 传入方法名和路径等参数 authInfo = await getAuthInfo({ method: 'Sync.getVerifiedDownLoadUrl', extraParams: extraParams }, transportAuthInfo); authInfo.request_url = getConfig(transportAuthInfo?.ecsEnv).request_url } catch (authError) { throw new Error("获取鉴权信息失败,请提供有效的API_KEY"); } // 调用API获取下载链接 const apiResponse = await getDownloadUrl(authInfo, nid); if (apiResponse && apiResponse.errno === 0) { const downloadData = apiResponse.data || {}; const downloadUrl = downloadData.downloadUrl || ''; if (!downloadUrl) { return { content: [{ type: "text", text: "未能获取到文件下载链接" }], }; } // 从URL中提取文件信息和大小 const {filename, sizeMB} = extractFileInfoFromUrl(downloadUrl); const fileSizeInfo = sizeMB > 0 ? `${sizeMB.toFixed(2)} MB` : "未知大小"; // 计算超时时间 const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes let estimatedTimeoutMs = DEFAULT_TIMEOUT_MS; const MAX_TIMEOUT_MS = 30 * 60 * 1000; // Max 30 minutes timeout const TIMEOUT_INCREMENT_PER_MB_MS = 1000; // 1 second per MB const BASE_SIZE_FOR_INCREMENTAL_TIMEOUT_MB = 200; // Apply incremental timeout for files larger than 200MB if (sizeMB > BASE_SIZE_FOR_INCREMENTAL_TIMEOUT_MB) { estimatedTimeoutMs = DEFAULT_TIMEOUT_MS + (sizeMB - BASE_SIZE_FOR_INCREMENTAL_TIMEOUT_MB) * TIMEOUT_INCREMENT_PER_MB_MS; estimatedTimeoutMs = Math.min(estimatedTimeoutMs, MAX_TIMEOUT_MS); } if (auto) { let initialMessage = `正在准备下载文件 (大小: ${fileSizeInfo}).`; if (estimatedTimeoutMs > DEFAULT_TIMEOUT_MS) { initialMessage += ` 由于文件较大,预计超时时间已调整为 ${(estimatedTimeoutMs / 60000).toFixed(1)} 分钟。`; } // It's tricky to send this initialMessage to MCP client before download starts in a single request-response. // For now, this console.error serves as a server-side log. try { // 使用从URL中提取的文件名 // 检查目录权限 const hasPermission = await checkAndCreateDirectory(downloadDir); if (!hasPermission) { return { content: [ { type: "text", text: `❌ 下载失败: 目录 ${downloadDir} 没有读写权限` }, { type: "text", text: TOOL_LIMIT_NOTE } ] }; } // 对于大于10MB的文件,使用后台下载 if (sizeMB > 10) { // 启动后台下载 downloadFileInBackground(downloadUrl, filename, downloadDir, estimatedTimeoutMs); return { content: [ { type: "text", text: `🚀 正在下载中,请稍后在"${downloadDir}"目录查看\n` + `📁 文件名:${filename}\n` + `🔗 下载链接:${downloadUrl}\n` + `📦 文件大小:${fileSizeInfo}` }, { type: "text", text: TOOL_LIMIT_NOTE } ] }; } // 对于小文件,等待下载完成 const { downloadPath, progressLog } = await downloadFileWithCurl(downloadUrl, filename, downloadDir, estimatedTimeoutMs); return { content: [ { type: "text", text: `✅ 文件下载完成!\n` + `📁 文件名:${filename}\n` + `💾 保存路径:${downloadPath}\n` + `🔗 下载链接:${downloadUrl}\n` + `📦 文件大小:${fileSizeInfo}` }, { type: "text", text: TOOL_LIMIT_NOTE } ] }; } catch (downloadError: any) { const errorMessage = downloadError.message || "未知下载错误"; return { content: [ { type: "text", text: `❌ 文件下载失败\n` + `📝 错误信息:${errorMessage}\n` + `🔗 下载链接:${downloadUrl}\n` + `📦 文件大小:${fileSizeInfo}` }, { type: "text", text: TOOL_LIMIT_NOTE } ] }; } } // 如果不下载,只返回链接和文件大小 return { content: [ { type: "text", text: `文件下载地址:${downloadUrl}\n📦 文件大小:${fileSizeInfo}` }, { type: "text", text: TOOL_LIMIT_NOTE } ] }; } else { throw new Error(apiResponse?.errmsg || "API请求失败"); } } catch (error: any) { return { content: [ { type: "text", text: `获取文件下载链接时发生错误: ${error.message}`, }, { type: "text", text: TOOL_LIMIT_NOTE, }, ], }; } }, ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Qihoo360/ecs_mcp_server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server