diff --git a/.gitignore b/.gitignore index b512c09..002578d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -node_modules \ No newline at end of file +node_modules + +ecosystem.config.js +*.log +_data \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..0e9e0e1 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,65 @@ +# Apifox MCP 服务上下文 + +## 项目概述 +本项目是一个针对 **Apifox** 的 **Model Context Protocol (MCP) 服务器**。它充当大型语言模型 (LLM)(如 Claude 或 Gemini)与 Apifox API 文档之间的桥梁。它允许 AI 助手直接从 Apifox 项目中检索强类型的 API 定义 (Schema),从而生成准确的 API 请求代码。 + +## 架构 +- **`index.js`**: 主入口点。它设置 MCP 服务器,注册工具,并处理传输(Stdio 或 HTTP/SSE)。 +- **`apifox.js`**: 包含 `ApifoxService` 类。它封装了与 Apifox API 交互的逻辑,处理身份验证、缓存,并递归解析 JSON Schema 引用 (`$ref`) 以提供自包含的 API 定义。 + +## 构建与运行 + +### 前置要求 +- Node.js (v18+) +- Apifox 账号及项目 + +### 环境变量 +需要以下环境变量: +- `APIFOX_PROJECT_ID`: 目标 Apifox 项目 ID (数字)。 +- `APIFOX_TOKEN`: Apifox 个人访问令牌 (Bearer Token)。 + +### 命令 +- **安装依赖**: + ```bash + npm install + ``` +- **启动 (Stdio 模式)** - *默认,用于与 Claude Desktop 集成*: + ```bash + node index.js + # 或者显式指定: + node index.js --transport stdio + ``` +- **启动 (HTTP/SSE 模式)** - *用于远程部署或检查*: + ```bash + node index.js --transport http --port 8889 + ``` + - **端点**: `http://localhost:8889/sse` + - **健康检查**: `http://localhost:8889/health` + +## 关键文件 + +- **`index.js`**: + - 初始化 `McpServer`。 + - 注册工具: `get_api_by_path`, `get_apis_by_folder`。 + - 处理传输选择的命令行参数。 +- **`apifox.js`**: + - `ApifoxService` 类实现。 + - 从 `https://apifox.gzmiyuan.com/api/v1` 获取数据。 + - 使用 `node-cache` 实现缓存策略。 + - 包含扁平化和解析复杂 JSON schema 以供 LLM 使用的逻辑。 + +## 可用工具 + +服务器向 MCP 客户端公开以下工具: + +1. **`get_api_by_path`**: + - **描述**: 根据路径检索详细的 API 定义 schema。 + - **输入**: `path` (字符串), `projectId` (数字, 可选)。 +2. **`get_apis_by_folder`**: + - **描述**: 检索特定文件夹内的所有 API 定义。 + - **输入**: `folderName` (字符串), `projectId` (数字, 可选)。 + +## 开发约定 +- **ES Modules**: 项目使用 ES 模块(`package.json` 中的 `"type": "module"`)。 +- **错误处理**: 工具返回带有 `isError: true` 的结构化错误响应,而不是崩溃。 +- **日志记录**: Stdio 传输使用 `stderr` 进行日志记录,以避免干扰 `stdout` 上的 JSON-RPC 消息。 \ No newline at end of file diff --git a/apifox.js b/apifox.js index b4ff793..8be74c2 100644 --- a/apifox.js +++ b/apifox.js @@ -1,376 +1,443 @@ -import axios from "axios"; -import NodeCache from 'node-cache'; -import fs from 'fs'; -import path from 'path'; - -const APIFOX_API_BASE = "https://apifox.gzmiyuan.com/api/v1"; - -export class ApifoxService { - constructor(config = {}) { - const { projectId, token, baseUrl, logFilePath } = config; - this.projectId = projectId; - this.branchId = null; - this.apiTreeList = null; - this.logFilePath = logFilePath || path.join(process.cwd(), 'apifox-service.log'); - this.http = axios.create({ - baseURL: baseUrl || APIFOX_API_BASE, - headers: { - "X-Client-Mode": "web", - "X-Client-Version": "2.7.19", - "X-Device-Id": "bd94aea0-7b65-48ba-9274-4b1177897954", - "Authorization": `Bearer ${token}` - } - }); - - // 初始化node-cache,设置默认5分钟过期时间 - this.cache = new NodeCache({ - stdTTL: 5 * 60, // 5分钟,单位秒 - checkperiod: 60, // 每分钟检查一次过期缓存 - useClones: false // 创建缓存对象的克隆以避免引用问题 - }); - - this.init(); - } - - /** - * 清除特定缓存 - * @param {string} cacheKey - 缓存键名 - */ - clearCache(cacheKey) { - if (cacheKey) { - this.cache.del(cacheKey); - } else { - // 清除所有缓存 - this.cache.flushAll(); - } - } - - /** - * 写入日志到文件 - * @param {string} level - 日志级别 (INFO, ERROR, WARN, DEBUG) - * @param {string} message - 日志消息 - * @param {object} data - 可选的额外数据 - */ - logToFile(level, message, data = null) { - try { - const timestamp = new Date().toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }); - - let logLine = `[${timestamp}] ${level.toUpperCase()}: ${message}`; - - if (data) { - if (typeof data === 'object') { - logLine += `\n 数据: ${JSON.stringify(data, null, 2)}`; - } else { - logLine += `\n 数据: ${data}`; - } - } - - logLine += '\n'; - - // 确保日志目录存在 - const logDir = path.dirname(this.logFilePath); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - - // 追加写入日志文件 - fs.appendFileSync(this.logFilePath, logLine); - - // 同时输出到控制台 - } catch (error) { - console.error('写入日志失败:', error); - } - } - - /** - * 初始化服务,获取branchId - * @returns {Promise} - */ - async init(projectId) { - if (projectId) { - this.projectId = projectId; - this.branchId = await this.getBranchId(); - } - } - - /** - * 发送GET请求 - * @param {string} url - 请求的URL路径 - * @param {object} config - axios配置对象 - * @returns {Promise} 返回响应数据中的data字段 - */ - async get(url, config = {}) { - try { - const response = await this.http.get(url, { - ...config, - headers: { - ...config.headers, - "X-Project-Id": this.projectId, - "X-Branch-Id": this.branchId, - }, - }); - return response.data; - } catch (error) { - throw error; - } - } - - /** - * 发送POST请求 - * @param {string} url - 请求的URL路径 - * @param {object} data - 请求体数据 - * @param {object} config - axios配置对象 - * @returns {Promise} 返回响应数据中的data字段 - */ - async post(url, data = {}, config = {}) { - try { - const response = await this.http.post(url, data, { - ...config, - headers: { - ...config.headers, - "X-Project-Id": this.projectId, - "X-Branch-Id": this.branchId, - }, - }); - return response.data; - } catch (error) { - throw error; - } - } - - - async getBranchId() { - const branches = await this.get(`/projects/${this.projectId}/api-versions`); - return branches.data[0].id; - } - - async getApiTreeList() { - const apiTree = await this.get(`/projects/${this.projectId}/api-tree-list`); - this.apiTreeList = apiTree; - return apiTree; - } - - async getFolderList() { - // 定义缓存键 - const cacheKey = 'folderList'; - - // 检查缓存是否有效 - const cachedData = this.cache.get(cacheKey); - if (cachedData) { - return cachedData; - } - - const {data: folderList} = await this.get(`/projects/${this.projectId}/api-detail-folders`); - - // 设置缓存 - this.cache.set(cacheKey, folderList); - return folderList; - } - - async getFolderByName(folderName) { - const folderList = await this.getFolderList(); - const folder = folderList.find((item) => item.name === folderName); - if (!folder) { - throw new Error(`Folder with name ${folderName} not found`); - } - return folder; - } - async getDataSchema() { - // 定义缓存键 - const cacheKey = 'dataSchema'; - - // 检查缓存是否有效 - const cachedData = this.cache.get(cacheKey); - if (cachedData) { - return cachedData; - } - - const {data:schemasTreeList} = await this.get(`/projects/${this.projectId}/data-schemas?locale=zh-CN`); - - // 设置缓存 - this.cache.set(cacheKey, schemasTreeList); - return schemasTreeList; - } - - async getApiDetails() { - // 定义缓存键 - const cacheKey = 'apiDetails'; - - // 检查缓存是否有效 - const cachedData = this.cache.get(cacheKey); - if (cachedData) { - return cachedData; - } - - const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, {}); - - // 设置缓存 - this.cache.set(cacheKey, apiDetails); - return apiDetails; - } - - getSchemaRefId(obj) { - if (!obj || !obj.$ref) { - return; - } - const schemaName = obj.$ref.split('/').pop(); - return schemaName; - } - - /** - * 根据schema ID获取完整的schema定义,递归替换所有$ref引用 - * @param {number|string} schemaId - schema的ID - * @param {Array} allSchemas - 所有schema数据数组(可选,用于递归) - * @param {Set} visitedIds - 已访问的ID集合,用于防止循环引用 - * @returns {Promise} 返回完整解析后的schema对象 - */ - async getSchemaById(schemaId, allSchemas = null, visitedIds = new Set()) { - if (!schemaId) { - return null; - } - // 如果没有传入allSchemas,则获取所有schema数据 - if (!allSchemas) { - allSchemas = await this.getDataSchema(); - } - - // 查找对应ID的schema - const schema = allSchemas.find((item) => item.id.toString() === schemaId.toString()); - if (!schema) { - throw new Error(`Schema with id ${schemaId} not found`); - } - - // 防止循环引用 - if (visitedIds.has(schemaId.toString())) { - return { type: 'object', description: `循环引用: ${schema.name}` }; - } - visitedIds.add(schemaId.toString()); - - // 深拷贝schema以避免修改原始数据 - const resolvedSchema = JSON.parse(JSON.stringify(schema)); - - // 递归替换jsonSchema中的所有$ref - resolvedSchema.jsonSchema = await this.resolveRefs( - resolvedSchema.jsonSchema, - allSchemas, - new Set(visitedIds) - ); - - // 移除不需要的字段 - const { description, folderId, projectId, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema; - - return cleanSchema; - } - - /** - * 递归解析对象中的所有$ref引用 - * @param {object} obj - 要解析的对象 - * @param {Array} allSchemas - 所有schema数据数组 - * @param {Set} visitedIds - 已访问的ID集合 - * @returns {Promise} 返回解析后的对象 - */ - async resolveRefs(obj, allSchemas, visitedIds) { - if (!obj || typeof obj !== 'object') { - return obj; - } - - // 如果是数组,递归处理每个元素 - if (Array.isArray(obj)) { - const resolved = []; - for (const item of obj) { - resolved.push(await this.resolveRefs(item, allSchemas, visitedIds)); - } - return resolved; - } - - // 如果对象包含$ref,替换为实际的schema定义 - if (obj.$ref) { - const refId = this.getSchemaRefId(obj); - const referencedSchema = await this.getSchemaById(refId, allSchemas, visitedIds); - // 返回引用的jsonSchema内容 - return referencedSchema.jsonSchema; - } - - // 递归处理对象的所有属性 - const resolved = {}; - for (const [key, value] of Object.entries(obj)) { - resolved[key] = await this.resolveRefs(value, allSchemas, visitedIds); - } - return resolved; - } - - async getApiDetailResp(apiDetail) { - const dataSchema = await this.getDataSchema(); - const requestSchemaId = this.getSchemaRefId(apiDetail.requestBody.jsonSchema); - const responseSchema = Array.isArray(apiDetail.responses) ? apiDetail.responses[0].jsonSchema : apiDetail.responses.jsonSchema; - const responseSchemaId = this.getSchemaRefId(responseSchema); - const responseDataSchema = dataSchema.find((item) => item.id.toString() === responseSchemaId).jsonSchema.properties.data; - - let respDataSchemaId; - const isArray = responseDataSchema.type === 'array'; - // 处理数组和对象两种情况 - if (isArray && responseDataSchema.items && responseDataSchema.items.$ref) { - // 数组情况:从 items.$ref 中提取 schema ID - respDataSchemaId = this.getSchemaRefId(responseDataSchema.items); - } else if (responseDataSchema.$ref) { - // 对象情况:直接从 $ref 中提取 schema ID - respDataSchemaId = this.getSchemaRefId(responseDataSchema); - } else { - // 如果都没有 $ref,记录警告并返回 null - this.logToFile('WARN', `无法从 responseDataSchema 中提取 schema ID`, responseDataSchema); - respDataSchemaId = null; - } - - return { - path: apiDetail.path, - name: apiDetail.name, - method: apiDetail.method, - url: apiDetail.url, - requestSchema: await this.getSchemaById(requestSchemaId), - responseSchema: isArray ? { - type: 'array', - items: respDataSchemaId ? await this.getSchemaById(respDataSchemaId) : null, - } : await this.getSchemaById(respDataSchemaId), - }; - } - - async getApiDetailByPath(path, projectId) { - if (projectId) { - await this.init(projectId); - } - const apiDetails = await this.getApiDetails(); - const apiDetail = apiDetails.find((item) => item.path === path); - if (!apiDetail) { - throw new Error(`API with path ${path} not found`); - } - return this.getApiDetailResp(apiDetail); - } - - async getApiDetailsById(apiId) { - const apiDetails = await this.getApiDetails(); - const apiDetail = apiDetails.find((item) => item.id === apiId); - if (!apiDetail) { - throw new Error(`API with id ${id} not found`); - } - return this.getApiDetailResp(apiDetail); - } - - - // TODO: - async getApiListByFolderName(folderName, projectId) { - if (projectId) { - await this.init(projectId); - } - const folder = await this.getFolderByName(folderName); - const apiDetails = await this.getApiDetails(); - const apiList = apiDetails.filter((item) => item.folderId === folder.id); - return Promise.all(apiList.map((api) => this.getApiDetailResp(api))); - } - -} +import axios from "axios"; +import NodeCache from 'node-cache'; +import fs from 'fs'; +import path from 'path'; + +const APIFOX_API_BASE = "https://apifox.gzmiyuan.com/api/v1"; + +// 初始化node-cache,设置默认5分钟过期时间 +// 全局共享缓存,避免每次实例化清空缓存 +const globalCache = new NodeCache({ + stdTTL: 5 * 60, // 5分钟,单位秒 + checkperiod: 60, // 每分钟检查一次过期缓存 + useClones: false // 创建缓存对象的克隆以避免引用问题 +}); + +export class ApifoxService { + constructor(config = {}) { + const { projectId, token, baseUrl, logFilePath } = config; + this.defaultProjectId = projectId; + this.projectBranchIds = new Map(); // Cache branchIds: projectId -> branchId + this.apiTreeList = null; + this.logFilePath = logFilePath || path.join(process.cwd(), 'apifox-service.log'); + this.http = axios.create({ + baseURL: baseUrl || APIFOX_API_BASE, + headers: { + "X-Client-Mode": "web", + "X-Client-Version": "2.7.19", + "X-Device-Id": "bd94aea0-7b65-48ba-9274-4b1177897954", + "Authorization": `Bearer ${token}` + } + }); + + this.cache = globalCache; + + this.init(); + } + + /** + * 清除特定缓存 + * @param {string} cacheKey - 缓存键名 + */ + clearCache(cacheKey) { + if (cacheKey) { + this.cache.del(cacheKey); + } else { + // 清除所有缓存 + this.cache.flushAll(); + } + } + + /** + * 写入日志到文件 + * @param {string} level - 日志级别 (INFO, ERROR, WARN, DEBUG) + * @param {string} message - 日志消息 + * @param {object} data - 可选的额外数据 + */ + logToFile(level, message, data = null) { + try { + const timestamp = new Date().toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + let logLine = `[${timestamp}] ${level.toUpperCase()}: ${message}`; + + if (data) { + if (typeof data === 'object') { + logLine += `\n 数据: ${JSON.stringify(data, null, 2)}`; + } else { + logLine += `\n 数据: ${data}`; + } + } + + logLine += '\n'; + + // 确保日志目录存在 + const logDir = path.dirname(this.logFilePath); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + // 追加写入日志文件 + fs.appendFileSync(this.logFilePath, logLine); + + // 同时输出到控制台 + } catch (error) { + console.error('写入日志失败:', error); + } + } + + async init() { + // Pre-fetch branchId for default project if set + if (this.defaultProjectId) { + await this.getBranchId(this.defaultProjectId); + } + } + + /** + * 发送GET请求 + * @param {string} url - 请求的URL路径 + * @param {object} config - axios配置对象 (contains headers with projectId/branchId) + * @returns {Promise} 返回响应数据中的data字段 + */ + async get(url, config = {}) { + try { + const response = await this.http.get(url, config); + return response.data; + } catch (error) { + // Enhance error logging if needed + throw error; + } + } + + /** + * 发送POST请求 + * @param {string} url - 请求的URL路径 + * @param {object} data - 请求体数据 + * @param {object} config - axios配置对象 + * @returns {Promise} 返回响应数据中的data字段 + */ + async post(url, data = {}, config = {}) { + try { + const response = await this.http.post(url, data, config); + return response.data; + } catch (error) { + throw error; + } + } + + async getRequestConfig(projectId) { + const pid = projectId || this.defaultProjectId; + if (!pid) throw new Error("No Project ID provided"); + + const branchId = await this.getBranchId(pid); + return { + headers: { + "X-Project-Id": pid, + "X-Branch-Id": branchId, + } + }; + } + + + async getBranchId(projectId) { + if (this.projectBranchIds.has(projectId)) { + return this.projectBranchIds.get(projectId); + } + + // We need to fetch branch ID. + // BUT fetching branch ID requires Project ID header usually? + // Let's check the original implementation: + // const branches = await this.get(`/projects/${this.projectId}/api-versions`); + // It used `this.http` which had `this.projectId` in headers? + // WAIT, original `get` implementation injected headers dynamically based on `this.projectId`. + + // So here we must manually construct headers for this specific request to avoid circular dependency + // or just pass X-Project-Id. + + try { + const response = await this.http.get(`/projects/${projectId}/api-versions`, { + headers: { + "X-Project-Id": projectId + } + }); + + const branchId = response.data.data[0].id; + this.projectBranchIds.set(projectId, branchId); + return branchId; + } catch (error) { + console.error(`Failed to get branch ID for project ${projectId}`, error); + throw error; + } + } + + async getApiTreeList(projectId) { + const pid = projectId || this.defaultProjectId; + const cacheKey = `${pid}:apiTreeList`; + + const cachedData = this.cache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + const config = await this.getRequestConfig(pid); + const apiTree = await this.get(`/projects/${pid}/api-tree-list`, config); + + this.cache.set(cacheKey, apiTree); + return apiTree; + } + + async getFolderList(projectId) { + const pid = projectId || this.defaultProjectId; + // 定义缓存键 + const cacheKey = `${pid}:folderList`; + + // 检查缓存是否有效 + const cachedData = this.cache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + const config = await this.getRequestConfig(pid); + const {data: folderList} = await this.get(`/projects/${pid}/api-detail-folders`, config); + + // 设置缓存 + this.cache.set(cacheKey, folderList); + return folderList; + } + + async getFolderByName(folderName, projectId) { + const folderList = await this.getFolderList(projectId); + const folder = folderList.find((item) => item.name === folderName); + if (!folder) { + throw new Error(`Folder with name ${folderName} not found`); + } + return folder; + } + async getDataSchema(projectId) { + const pid = projectId || this.defaultProjectId; + // 定义缓存键 + const cacheKey = `${pid}:dataSchema`; + + // 检查缓存是否有效 + const cachedData = this.cache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + const config = await this.getRequestConfig(pid); + const {data:schemasTreeList} = await this.get(`/projects/${pid}/data-schemas?locale=zh-CN`, config); + + // 设置缓存 + this.cache.set(cacheKey, schemasTreeList); + return schemasTreeList; + } + + async getApiDetails(projectId) { + const pid = projectId || this.defaultProjectId; + // 定义缓存键 + const cacheKey = `${pid}:apiDetails`; + + // 检查缓存是否有效 + const cachedData = this.cache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + const config = await this.getRequestConfig(pid); + const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, config); + + // 设置缓存 + this.cache.set(cacheKey, apiDetails); + return apiDetails; + } + + getSchemaRefId(obj) { + if (!obj || !obj.$ref) { + return; + } + const schemaName = obj.$ref.split('/').pop(); + return schemaName; + } + + /** + * 根据schema ID获取完整的schema定义,递归替换所有$ref引用 + * @param {number|string} schemaId - schema的ID + * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds } + * @returns {Promise} 返回完整解析后的schema对象 + */ + async getSchemaById(schemaId, context = {}) { + if (!schemaId) { + return null; + } + + let { projectId, allSchemas, visitedIds } = context; + visitedIds = visitedIds || new Set(); + + // 如果没有传入allSchemas,则获取所有schema数据 + if (!allSchemas) { + allSchemas = await this.getDataSchema(projectId); + context.allSchemas = allSchemas; // Update context for recursion + } + + // 查找对应ID的schema + const schema = allSchemas.find((item) => item.id.toString() === schemaId.toString()); + if (!schema) { + throw new Error(`Schema with id ${schemaId} not found`); + } + + // 防止循环引用 + if (visitedIds.has(schemaId.toString())) { + return { type: 'object', description: `循环引用: ${schema.name}` }; + } + visitedIds.add(schemaId.toString()); + + // Update context with new visitedIds for recursion (create new Set for branch safety if needed, but sharing Set prevents cycles across branches which is usually desired for DAGs) + // Actually, usually visitedIds should be cloned for separate branches if we want to allow same ref in different parts, but for circular detection, shared set is stricter. + // However, existing code passed `new Set(visitedIds)` to resolveRefs. + + // 深拷贝schema以避免修改原始数据 + const resolvedSchema = JSON.parse(JSON.stringify(schema)); + + // 递归替换jsonSchema中的所有$ref + resolvedSchema.jsonSchema = await this.resolveRefs( + resolvedSchema.jsonSchema, + { ...context, visitedIds: new Set(visitedIds) } + ); + + // 移除不需要的字段 + const { description, folderId, projectId: pid, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema; + + return cleanSchema; + } + + /** + * 递归解析对象中的所有$ref引用 + * @param {object} obj - 要解析的对象 + * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds } + * @returns {Promise} 返回解析后的对象 + */ + async resolveRefs(obj, context) { + if (!obj || typeof obj !== 'object') { + return obj; + } + + // 如果是数组,递归处理每个元素 + if (Array.isArray(obj)) { + const resolved = []; + for (const item of obj) { + resolved.push(await this.resolveRefs(item, context)); + } + return resolved; + } + + // 如果对象包含$ref,替换为实际的schema定义 + if (obj.$ref) { + const refId = this.getSchemaRefId(obj); + // Calls getSchemaById with context + const referencedSchema = await this.getSchemaById(refId, context); + // 返回引用的jsonSchema内容 + return referencedSchema.jsonSchema; + } + + // 递归处理对象的所有属性 + const resolved = {}; + for (const [key, value] of Object.entries(obj)) { + resolved[key] = await this.resolveRefs(value, context); + } + return resolved; + } + + async getApiDetailResp(apiDetail, projectId) { + const pid = projectId || this.defaultProjectId; + const dataSchema = await this.getDataSchema(pid); + const context = { projectId: pid, allSchemas: dataSchema }; + + const requestSchemaId = this.getSchemaRefId(apiDetail.requestBody.jsonSchema); + const responseSchema = Array.isArray(apiDetail.responses) ? apiDetail.responses[0].jsonSchema : apiDetail.responses.jsonSchema; + const responseSchemaId = this.getSchemaRefId(responseSchema); + + // Find the response data schema property + let responseDataSchema; + try { + const schema = dataSchema.find((item) => item.id.toString() === responseSchemaId); + if (schema && schema.jsonSchema && schema.jsonSchema.properties) { + responseDataSchema = schema.jsonSchema.properties.data; + } + } catch (e) { + // Fallback or ignore if structure not found + } + + if (!responseDataSchema) { + // handle case where structure is different or missing + // For now, let's assume if we can't find it, we might just try to return null for response schema or log warning + this.logToFile('WARN', `无法找到 responseDataSchema`, { responseSchemaId }); + // Proceeding with null might be safer than crashing below + } + + let respDataSchemaId; + if (responseDataSchema) { + const isArray = responseDataSchema.type === 'array'; + // 处理数组和对象两种情况 + if (isArray && responseDataSchema.items && responseDataSchema.items.$ref) { + // 数组情况:从 items.$ref 中提取 schema ID + respDataSchemaId = this.getSchemaRefId(responseDataSchema.items); + } else if (responseDataSchema.$ref) { + // 对象情况:直接从 $ref 中提取 schema ID + respDataSchemaId = this.getSchemaRefId(responseDataSchema); + } else { + // 如果都没有 $ref,记录警告并返回 null + this.logToFile('WARN', `无法从 responseDataSchema 中提取 schema ID`, responseDataSchema); + respDataSchemaId = null; + } + } + + return { + path: apiDetail.path, + name: apiDetail.name, + method: apiDetail.method, + url: apiDetail.url, + requestSchema: await this.getSchemaById(requestSchemaId, context), + responseSchema: (responseDataSchema && responseDataSchema.type === 'array') ? { + type: 'array', + items: respDataSchemaId ? await this.getSchemaById(respDataSchemaId, context) : null, + } : await this.getSchemaById(respDataSchemaId, context), + }; + } + + async getApiDetailByPath(path, projectId) { + const pid = projectId || this.defaultProjectId; + const apiDetails = await this.getApiDetails(pid); + const apiDetail = apiDetails.find((item) => item.path === path); + if (!apiDetail) { + throw new Error(`API with path ${path} not found`); + } + return this.getApiDetailResp(apiDetail, pid); + } + + async getApiDetailsById(apiId, projectId) { + const pid = projectId || this.defaultProjectId; + const apiDetails = await this.getApiDetails(pid); + const apiDetail = apiDetails.find((item) => item.id === apiId); + if (!apiDetail) { + throw new Error(`API with id ${apiId} not found`); + } + return this.getApiDetailResp(apiDetail, pid); + } + + + // TODO: + async getApiListByFolderName(folderName, projectId) { + const pid = projectId || this.defaultProjectId; + const folder = await this.getFolderByName(folderName, pid); + const apiDetails = await this.getApiDetails(pid); + const apiList = apiDetails.filter((item) => item.folderId === folder.id); + return Promise.all(apiList.map((api) => this.getApiDetailResp(api, pid))); + } + +} diff --git a/index.js b/index.js index 87c2ad9..32fa1fa 100644 --- a/index.js +++ b/index.js @@ -1,234 +1,196 @@ -#!/usr/bin/env node -// 引入必要的模块 -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import http from "http"; -import { z } from "zod"; -import { ApifoxService } from './apifox.js' - -// 创建 MCP 服务器实例 -// 为其设置一个名字和版本 -const server = new McpServer( - { - name: "apifox-mcp-server", - version: "1.0.0", - }, - { - capabilities: { - prompts: {}, - tools: {}, - }, - } -); - - -const apifoxService = new ApifoxService({ - projectId: Number(process.env.APIFOX_PROJECT_ID), - token: process.env.APIFOX_TOKEN -}); - - -server.registerTool( - 'get_api_by_path', - { - description: "根据接口path获取Apifox接口定义数据", - inputSchema: z.object({ - path: z.string().describe("接口路径"), - projectId: z.number().optional().describe("Apifox项目ID"), - }), - }, - - async ({ path, projectId }) => { - try { - - const apiData = await apifoxService.getApiDetailByPath(path, projectId); - - return { - content: [ - { - type: "text", - text: JSON.stringify(apiData, null, 2), - }, - ], - }; - - } catch (error) { - return { - content: [ - { - type: "text", - text: `获取接口详情失败: ${error.message}`, - }, - ], - isError: true, - }; - } - } -) -server.registerTool( - 'get_apis_by_folder', - { - description: "根据接口目录获取Apifox目录下所有接口定义数据", - inputSchema: z.object({ - folderName: z.string().describe("接口路径"), - projectId: z.number().optional().describe("Apifox项目ID"), - }), - }, - - async ({ folderName, projectId }) => { - try { - - const apiData = await apifoxService.getApiListByFolderName(folderName, projectId); - - return { - content: [ - { - type: "text", - text: JSON.stringify(apiData, null, 2), - }, - ], - }; - - } catch (error) { - return { - content: [ - { - type: "text", - text: `获取接口数据失败: ${error.message}`, - }, - ], - isError: true, - }; - } - } -) - - - -// 定义 prompts -server.registerPrompt( - 'gen-api-by-path', - { - title: 'Generate API by Path', - description: '根据输入的接口路径获取Apifox接口文档定义,然后根据定义生成 api 接口代码定义', - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `请根据以下步骤获取Apifox的接口定义并生成 api 定义代码。 - -步骤: -1. 使用 get_api_by_path 工具获取对应的接口定义schema -2. 根据返回的 path,method, requestSchema, responseSchema 和其他必要字段,生成 api 定义代码 -3. 接口的参数和返回值的类型定义,可以分别参考 requestSchema 和 responseSchema 里面的jsonSchema定义,可以根据description要补充相关的注释 -4. 所有字段先定义为可空类型 -5. 请根据当前项目的项目接口和代码风格生成 api 接口代码 - -请开始执行这些步骤。`, - }, - }, - ], - }) -); - -server.registerPrompt( - 'gen-api-by-folder', - { - title: 'Generate API by Folder', - description: '根据输入的接口文件夹获取Apifox相关文件夹下所有的接口文档定义,然后根据定义生成 api 接口代码定义', - }, - () => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `请根据以下步骤获取Apifox的接口定义并生成 api 定义代码。 - -步骤: -1. 使用 get_api_by_folder 工具获取对应的接口定义schema,可能会返回多条接口的数据 -2. 根据返回的 path,method, requestSchema, responseSchema 和其他必要字段,生成 api 定义代码 -3. 接口的参数和返回值的类型定义,可以分别参考 requestSchema 和 responseSchema 里面的jsonSchema定义,可以根据description要补充相关的注释 -4. 所有字段先定义为可空类型 -5. 请根据当前项目的项目接口和代码风格生成 api 接口代码 - -请开始执行这些步骤。`, - }, - }, - ], - }) -); - - - -// 启动服务器,使用标准输入/输出(stdio)与客户端通信 - -// 启动服务器,使用标准输入/输出(stdio)与客户端通信 -async function main() { - const args = process.argv.slice(2); - const transportArgIndex = args.indexOf('--transport') !== -1 ? args.indexOf('--transport') : args.indexOf('-t'); - const transportType = transportArgIndex !== -1 ? args[transportArgIndex + 1] : 'stdio'; - - const portArgIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p'); - const port = portArgIndex !== -1 ? parseInt(args[portArgIndex + 1]) : 8889; - - - - if (transportType === 'http') { - const transport = new StreamableHTTPServerTransport({}); - await server.connect(transport); - - const httpServer = http.createServer(async (req, res) => { - // 设置 CORS 头 - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); - - if (req.method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - return; - } - - try { - await transport.handleRequest(req, res); - } catch (error) { - console.error("Error handling request:", error); - if (!res.headersSent) { - res.writeHead(500); - res.end("Internal Server Error"); - } - } - }); - - httpServer.listen(port, () => { - console.error(`Apifox MCP 服务器已通过 http 启动,监听端口 ${port}`); - console.error(`MCP 服务 endpoint: http://localhost:${port}/sse`); - }); - } else { - - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Apifox MCP 服务器已通过 stdio 启动"); // 日志输出到 stderr - } - -} - - -// 运行主函数,并捕获可能的错误 -main().catch((error) => { - console.error("服务器运行出现致命错误:", error); - process.exit(1); -}); - +#!/usr/bin/env node +// 引入必要的模块 +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import http from "http"; +import { z } from "zod"; +import { AsyncLocalStorage } from 'async_hooks'; +import { ApifoxService } from './apifox.js' + +const requestContext = new AsyncLocalStorage(); + +// 创建 MCP 服务器实例 +// 为其设置一个名字和版本 +const server = new McpServer( + { + name: "apifox-mcp-server", + version: "1.0.0", + }, + { + capabilities: { + prompts: {}, + tools: {}, + }, + } +); + +function getApifoxService() { + const store = requestContext.getStore(); + const token = store?.token || process.env.APIFOX_TOKEN; + const projectId = store?.projectId ? Number(store.projectId) : Number(process.env.APIFOX_PROJECT_ID); + + if (!token) { + throw new Error("Apifox Token not configured (env: APIFOX_TOKEN or header: Authorization)"); + } + + return new ApifoxService({ + projectId, + token + }); +} + +server.registerTool( + 'get_api_by_path', + { + description: "根据接口path获取Apifox接口定义数据", + inputSchema: z.object({ + path: z.string().describe("接口路径"), + projectId: z.number().optional().describe("Apifox项目ID"), + }), + }, + + async ({ path, projectId }) => { + try { + + const apifoxService = getApifoxService(); + const apiData = await apifoxService.getApiDetailByPath(path, projectId); + + return { + content: [ + { + type: "text", + text: JSON.stringify(apiData, null, 2), + }, + ], + }; + + } catch (error) { + return { + content: [ + { + type: "text", + text: `获取接口详情失败: ${error.message}`, + }, + ], + isError: true, + }; + } + } +) +server.registerTool( + 'get_apis_by_folder', + { + description: "根据接口目录获取Apifox目录下所有接口定义数据", + inputSchema: z.object({ + folderName: z.string().describe("接口路径"), + projectId: z.number().optional().describe("Apifox项目ID"), + }), + }, + + async ({ folderName, projectId }) => { + try { + + const apifoxService = getApifoxService(); + const apiData = await apifoxService.getApiListByFolderName(folderName, projectId); + + return { + content: [ + { + type: "text", + text: JSON.stringify(apiData, null, 2), + }, + ], + }; + + } catch (error) { + return { + content: [ + { + type: "text", + text: `获取接口数据失败: ${error.message}`, + }, + ], + isError: true, + }; + } + } +) + + +// 启动服务器,使用标准输入/输出(stdio)与客户端通信 +async function main() { + const args = process.argv.slice(2); + const transportArgIndex = args.indexOf('--transport') !== -1 ? args.indexOf('--transport') : args.indexOf('-t'); + const transportType = transportArgIndex !== -1 ? args[transportArgIndex + 1] : 'stdio'; + + const portArgIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p'); + const port = portArgIndex !== -1 ? parseInt(args[portArgIndex + 1]) : 8889; + + + + if (transportType === 'http') { + const transport = new StreamableHTTPServerTransport({}); + await server.connect(transport); + + const httpServer = http.createServer(async (req, res) => { + // 设置 CORS 头 + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + const projectId = req.headers['x-apifox-project-id']; + + const context = {}; + if (token) context.token = token; + if (projectId) context.projectId = projectId; + + requestContext.run(context, async () => { + try { + await transport.handleRequest(req, res); + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } + }); + }); + + httpServer.listen(port, () => { + console.error(`Apifox MCP 服务器已通过 http 启动,监听端口 ${port}`); + console.error(`MCP 服务 endpoint: http://localhost:${port}/sse`); + console.error(`MCP 消息 endpoint: http://localhost:${port}/messages`); + }); + } else { + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Apifox MCP 服务器已通过 stdio 启动"); // 日志输出到 stderr + } + +} + + +// 运行主函数,并捕获可能的错误 +main().catch((error) => { + console.error("服务器运行出现致命错误:", error); + process.exit(1); +}); + export { server }; \ No newline at end of file diff --git a/package.json b/package.json index bfa6d50..fca12ee 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,29 @@ -{ - "name": "apifox-mcp-server", - "version": "1.0.0", - "main": "index.js", - "type": "module", - "scripts": { - "start": "node index.js", - "dev": "npx @modelcontextprotocol/inspector node index.js" - }, - "keywords": [ - "mcp", - "apifox" - ], - "author": "Jasery", - "license": "ISC", - "description": "A Model Context Protocol server for Apifox", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", - "axios": "^1.13.2", - "node-cache": "^5.1.2", - "zod": "^3.25.76" - }, - "bin": { - "apifox-mcp-server": "./index.js" - }, - "devDependencies": { - "@modelcontextprotocol/inspector": "^0.17.2" - } -} +{ + "name": "apifox-mcp-server", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "npx @modelcontextprotocol/inspector node index.js" + }, + "keywords": [ + "mcp", + "apifox" + ], + "author": "Jasery", + "license": "ISC", + "description": "A Model Context Protocol server for Apifox", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.22.0", + "axios": "^1.13.2", + "node-cache": "^5.1.2", + "zod": "^3.25.76" + }, + "bin": { + "apifox-mcp-server": "./index.js" + }, + "devDependencies": { + "@modelcontextprotocol/inspector": "^0.17.2" + } +}