diff --git a/mcp/package-lock.json b/mcp/package-lock.json index bc76b59f..f140e82a 100644 --- a/mcp/package-lock.json +++ b/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudbase/cloudbase-mcp", - "version": "2.3.1", + "version": "2.4.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cloudbase/cloudbase-mcp", - "version": "2.3.1", + "version": "2.4.0-alpha.0", "license": "MIT", "dependencies": { "@cloudbase/cals": "^1.2.18-alpha.1", @@ -20,6 +20,7 @@ "cors": "^2.8.5", "express": "^5.1.0", "fd-slicer": "^1.1.0", + "lockfile": "^1.0.4", "miniprogram-ci": "^2.1.14", "open": "^10.1.2", "punycode": "^2.3.1", @@ -43,6 +44,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", + "@types/lockfile": "^1.0.4", "@types/node": "^22.10.2", "@types/ws": "^8.5.12", "buffer": "^6.0.3", @@ -5412,6 +5414,13 @@ "@types/koa": "*" } }, + "node_modules/@types/lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-Q8oFIHJHr+htLrTXN2FuZfg+WXVHQRwU/hC2GpUu+Q8e3FUM9EDkS2pE3R2AO1ZGu56f479ybdMCNF1DAu8cAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", @@ -12405,6 +12414,15 @@ "node": ">=6" } }, + "node_modules/lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", + "license": "ISC", + "dependencies": { + "signal-exit": "^3.0.2" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -21570,6 +21588,12 @@ "@types/koa": "*" } }, + "@types/lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-Q8oFIHJHr+htLrTXN2FuZfg+WXVHQRwU/hC2GpUu+Q8e3FUM9EDkS2pE3R2AO1ZGu56f479ybdMCNF1DAu8cAQ==", + "dev": true + }, "@types/lodash": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", @@ -26991,6 +27015,14 @@ "path-exists": "^3.0.0" } }, + "lockfile": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", + "integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==", + "requires": { + "signal-exit": "^3.0.2" + } + }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", diff --git a/mcp/package.json b/mcp/package.json index af96a69d..274f229b 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@cloudbase/cloudbase-mcp", - "version": "2.3.1", + "version": "2.4.0", "description": "腾讯云开发 MCP Server,通过AI提示词和MCP协议+云开发,让开发更智能、更高效,当你在Cursor/ VSCode GitHub Copilot/WinSurf/CodeBuddy/Augment Code/Claude Code等AI编程工具里写代码时,它能自动帮你生成可直接部署的前后端应用+小程序,并一键发布到腾讯云开发 CloudBase。", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -66,6 +66,7 @@ "cors": "^2.8.5", "express": "^5.1.0", "fd-slicer": "^1.1.0", + "lockfile": "^1.0.4", "miniprogram-ci": "^2.1.14", "open": "^10.1.2", "punycode": "^2.3.1", @@ -86,6 +87,7 @@ "@rollup/plugin-typescript": "^12.1.2", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", + "@types/lockfile": "^1.0.4", "@types/node": "^22.10.2", "@types/ws": "^8.5.12", "buffer": "^6.0.3", diff --git a/mcp/src/tools/rag.ts b/mcp/src/tools/rag.ts index f9815f6a..7dd0a396 100644 --- a/mcp/src/tools/rag.ts +++ b/mcp/src/tools/rag.ts @@ -1,5 +1,6 @@ import AdmZip from "adm-zip"; import * as fs from "fs/promises"; +import lockfile, { Options as LockfileOptions } from "lockfile"; import * as os from "os"; import * as path from "path"; import { z } from "zod"; @@ -19,7 +20,37 @@ const KnowledgeBaseIdMap: Record, string> = { // ============ 缓存配置 ============ const CACHE_BASE_DIR = path.join(os.homedir(), ".cloudbase-mcp"); const CACHE_META_FILE = path.join(CACHE_BASE_DIR, "cache-meta.json"); +const LOCK_FILE = path.join(CACHE_BASE_DIR, ".download.lock"); const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 默认 24 小时 + +// Promise wrapper for lockfile methods +function acquireLock( + lockPath: string, + options?: LockfileOptions, +): Promise { + return new Promise((resolve, reject) => { + if (options) { + lockfile.lock(lockPath, options, (err) => { + if (err) reject(err); + else resolve(); + }); + } else { + lockfile.lock(lockPath, (err) => { + if (err) reject(err); + else resolve(); + }); + } + }); +} + +function releaseLock(lockPath: string): Promise { + return new Promise((resolve, reject) => { + lockfile.unlock(lockPath, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} // 支持环境变量 CLOUDBASE_MCP_CACHE_TTL_MS 控制缓存过期时间(毫秒) const parsedCacheTTL = process.env.CLOUDBASE_MCP_CACHE_TTL_MS ? parseInt(process.env.CLOUDBASE_MCP_CACHE_TTL_MS, 10) @@ -124,32 +155,32 @@ const OPENAPI_SOURCES: Array<{ description: string; url: string; }> = [ - { - name: "mysqldb", - description: "MySQL RESTful API - 云开发 MySQL 数据库 HTTP API", - url: "https://docs.cloudbase.net/openapi/mysqldb.v1.openapi.yaml", - }, - { - name: "functions", - description: "Cloud Functions API - 云函数 HTTP API", - url: "https://docs.cloudbase.net/openapi/functions.v1.openapi.yaml", - }, - { - name: "auth", - description: "Authentication API - 身份认证 HTTP API", - url: "https://docs.cloudbase.net/openapi/auth.v1.openapi.yaml", - }, - { - name: "cloudrun", - description: "CloudRun API - 云托管服务 HTTP API", - url: "https://docs.cloudbase.net/openapi/cloudrun.v1.openapi.yaml", - }, - { - name: "storage", - description: "Storage API - 云存储 HTTP API", - url: "https://docs.cloudbase.net/openapi/storage.v1.openapi.yaml", - }, -]; + { + name: "mysqldb", + description: "MySQL RESTful API - 云开发 MySQL 数据库 HTTP API", + url: "https://docs.cloudbase.net/openapi/mysqldb.v1.openapi.yaml", + }, + { + name: "functions", + description: "Cloud Functions API - 云函数 HTTP API", + url: "https://docs.cloudbase.net/openapi/functions.v1.openapi.yaml", + }, + { + name: "auth", + description: "Authentication API - 身份认证 HTTP API", + url: "https://docs.cloudbase.net/openapi/auth.v1.openapi.yaml", + }, + { + name: "cloudrun", + description: "CloudRun API - 云托管服务 HTTP API", + url: "https://docs.cloudbase.net/openapi/cloudrun.v1.openapi.yaml", + }, + { + name: "storage", + description: "Storage API - 云存储 HTTP API", + url: "https://docs.cloudbase.net/openapi/storage.v1.openapi.yaml", + }, + ]; async function downloadWebTemplate() { const zipPath = path.join(CACHE_BASE_DIR, "web-cloudbase-project.zip"); @@ -232,21 +263,32 @@ async function downloadResources(): Promise { const webTemplateDir = path.join(CACHE_BASE_DIR, "web-template"); const openAPIDir = path.join(CACHE_BASE_DIR, "openapi"); - // 检查缓存是否有效 + // 如果已有下载任务在进行中,共享该 Promise + if (resourceDownloadPromise) { + debug("[downloadResources] 共享已有下载任务"); + return resourceDownloadPromise; + } + + // 先快速检查缓存(不需要锁,因为只是读取) if (await canUseCache()) { try { // 检查两个目录都存在 await Promise.all([fs.access(webTemplateDir), fs.access(openAPIDir)]); const files = await fs.readdir(openAPIDir); if (files.length > 0) { - debug("[downloadResources] 使用缓存"); + debug("[downloadResources] 使用缓存(快速路径)"); return { webTemplateDir, openAPIDocs: OPENAPI_SOURCES.map((source) => ({ name: source.name, description: source.description, - absolutePath: path.join(openAPIDir, `${source.name}.openapi.yaml`), - })).filter((item) => files.includes(`${item.name}.openapi.yaml`)), + absolutePath: path.join( + openAPIDir, + `${source.name}.openapi.yaml`, + ), + })).filter((item) => + files.includes(`${item.name}.openapi.yaml`), + ), }; } } catch { @@ -254,24 +296,68 @@ async function downloadResources(): Promise { } } - // 如果已有下载任务在进行中,共享该 Promise - if (resourceDownloadPromise) { - debug("[downloadResources] 共享已有下载任务"); - return resourceDownloadPromise; - } - - // 创建新的下载任务 + // 创建新的下载任务,使用文件锁保护 debug("[downloadResources] 开始新下载任务"); await fs.mkdir(CACHE_BASE_DIR, { recursive: true }); - resourceDownloadPromise = _doDownloadResources() - .then(async (result) => { + + resourceDownloadPromise = (async () => { + // 尝试获取文件锁,最多等待 6 秒(30 次 × 200ms),每 200ms 轮询一次 + let lockAcquired = false; + try { + await acquireLock(LOCK_FILE, { + wait: 30 * 200, // 总等待时间:6000ms (6 秒) + pollPeriod: 200, // 轮询间隔:200ms + stale: 5 * 60 * 1000, // 5 分钟,如果锁文件超过这个时间认为是过期的 + }); + lockAcquired = true; + debug("[downloadResources] 文件锁已获取"); + + // 在持有锁的情况下再次检查缓存(可能其他进程已经下载完成) + if (await canUseCache()) { + try { + // 检查两个目录都存在 + await Promise.all([fs.access(webTemplateDir), fs.access(openAPIDir)]); + const files = await fs.readdir(openAPIDir); + if (files.length > 0) { + debug("[downloadResources] 使用缓存(在锁保护下检查)"); + return { + webTemplateDir, + openAPIDocs: OPENAPI_SOURCES.map((source) => ({ + name: source.name, + description: source.description, + absolutePath: path.join( + openAPIDir, + `${source.name}.openapi.yaml`, + ), + })).filter((item) => + files.includes(`${item.name}.openapi.yaml`), + ), + }; + } + } catch { + // 缓存无效,需要重新下载 + } + } + + // 执行下载 + const result = await _doDownloadResources(); await updateCache(); debug("[downloadResources] 缓存已更新"); return result; - }) - .finally(() => { - resourceDownloadPromise = null; - }); + } finally { + // 释放文件锁 + if (lockAcquired) { + try { + await releaseLock(LOCK_FILE); + debug("[downloadResources] 文件锁已释放"); + } catch (error) { + warn("[downloadResources] 释放文件锁失败", { error }); + } + } + } + })().finally(() => { + resourceDownloadPromise = null; + }); return resourceDownloadPromise; } @@ -443,18 +529,17 @@ export async function registerRagTools(server: ExtendedMcpServer) { 固定文档 (doc) 查询当前支持 ${skills.length} 个固定文档,分别是: ${skills - .map( - (skill) => - `文档名:${path.basename(path.dirname(skill.absolutePath))} 文档介绍:${ - skill.description - }`, - ) - .join("\n")} + .map( + (skill) => + `文档名:${path.basename(path.dirname(skill.absolutePath))} 文档介绍:${skill.description + }`, + ) + .join("\n")} OpenAPI 文档 (openapi) 查询当前支持 ${openapis.length} 个 API 文档,分别是: ${openapis - .map((api) => `API名:${api.name} API介绍:${api.description}`) - .join("\n")}`, + .map((api) => `API名:${api.name} API介绍:${api.description}`) + .join("\n")}`, inputSchema: { mode: z.enum(["vector", "doc", "openapi"]), docName: z