feat: refactor for multi-tenant HTTP support and global caching

This commit is contained in:
Gemini Agent committed 2026-01-14 18:56:03 +08:00
1 parent 30d4c8a9da
commit 1ea6e0e9f0
5 files changed
+737 -639

No files matched your search

+5 -1
View File
@@ -1 +1,5 @@
node_modules node_modules
ecosystem.config.js
*.log
_data
+65
View File
@@ -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 消息。
+443 -376
View File
@@ -1,376 +1,443 @@
import axios from "axios"; import axios from "axios";
import NodeCache from 'node-cache'; import NodeCache from 'node-cache';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
const APIFOX_API_BASE = "https://apifox.gzmiyuan.com/api/v1"; const APIFOX_API_BASE = "https://apifox.gzmiyuan.com/api/v1";
export class ApifoxService { // 初始化node-cache,设置默认5分钟过期时间
constructor(config = {}) { // 全局共享缓存,避免每次实例化清空缓存
const { projectId, token, baseUrl, logFilePath } = config; const globalCache = new NodeCache({
this.projectId = projectId; stdTTL: 5 * 60, // 5分钟,单位秒
this.branchId = null; checkperiod: 60, // 每分钟检查一次过期缓存
this.apiTreeList = null; useClones: false // 创建缓存对象的克隆以避免引用问题
this.logFilePath = logFilePath || path.join(process.cwd(), 'apifox-service.log'); });
this.http = axios.create({
baseURL: baseUrl || APIFOX_API_BASE, export class ApifoxService {
headers: { constructor(config = {}) {
"X-Client-Mode": "web", const { projectId, token, baseUrl, logFilePath } = config;
"X-Client-Version": "2.7.19", this.defaultProjectId = projectId;
"X-Device-Id": "bd94aea0-7b65-48ba-9274-4b1177897954", this.projectBranchIds = new Map(); // Cache branchIds: projectId -> branchId
"Authorization": `Bearer ${token}` this.apiTreeList = null;
} this.logFilePath = logFilePath || path.join(process.cwd(), 'apifox-service.log');
}); this.http = axios.create({
baseURL: baseUrl || APIFOX_API_BASE,
// 初始化node-cache,设置默认5分钟过期时间 headers: {
this.cache = new NodeCache({ "X-Client-Mode": "web",
stdTTL: 5 * 60, // 5分钟,单位秒 "X-Client-Version": "2.7.19",
checkperiod: 60, // 每分钟检查一次过期缓存 "X-Device-Id": "bd94aea0-7b65-48ba-9274-4b1177897954",
useClones: false // 创建缓存对象的克隆以避免引用问题 "Authorization": `Bearer ${token}`
}); }
});
this.init();
} this.cache = globalCache;
/** this.init();
* 清除特定缓存 }
* @param {string} cacheKey - 缓存键名
*/ /**
clearCache(cacheKey) { * 清除特定缓存
if (cacheKey) { * @param {string} cacheKey - 缓存键名
this.cache.del(cacheKey); */
} else { clearCache(cacheKey) {
// 清除所有缓存 if (cacheKey) {
this.cache.flushAll(); this.cache.del(cacheKey);
} } else {
} // 清除所有缓存
this.cache.flushAll();
/** }
* 写入日志到文件 }
* @param {string} level - 日志级别 (INFO, ERROR, WARN, DEBUG)
* @param {string} message - 日志消息 /**
* @param {object} data - 可选的额外数据 * 写入日志到文件
*/ * @param {string} level - 日志级别 (INFO, ERROR, WARN, DEBUG)
logToFile(level, message, data = null) { * @param {string} message - 日志消息
try { * @param {object} data - 可选的额外数据
const timestamp = new Date().toLocaleString('zh-CN', { */
year: 'numeric', logToFile(level, message, data = null) {
month: '2-digit', try {
day: '2-digit', const timestamp = new Date().toLocaleString('zh-CN', {
hour: '2-digit', year: 'numeric',
minute: '2-digit', month: '2-digit',
second: '2-digit' day: '2-digit',
}); hour: '2-digit',
minute: '2-digit',
let logLine = `[${timestamp}] ${level.toUpperCase()}: ${message}`; second: '2-digit'
});
if (data) {
if (typeof data === 'object') { let logLine = `[${timestamp}] ${level.toUpperCase()}: ${message}`;
logLine += `\n 数据: ${JSON.stringify(data, null, 2)}`;
} else { if (data) {
logLine += `\n 数据: ${data}`; if (typeof data === 'object') {
} logLine += `\n 数据: ${JSON.stringify(data, null, 2)}`;
} } else {
logLine += `\n 数据: ${data}`;
logLine += '\n'; }
}
// 确保日志目录存在
const logDir = path.dirname(this.logFilePath); logLine += '\n';
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true }); // 确保日志目录存在
} const logDir = path.dirname(this.logFilePath);
if (!fs.existsSync(logDir)) {
// 追加写入日志文件 fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(this.logFilePath, logLine); }
// 同时输出到控制台 // 追加写入日志文件
} catch (error) { fs.appendFileSync(this.logFilePath, logLine);
console.error('写入日志失败:', error);
} // 同时输出到控制台
} } catch (error) {
console.error('写入日志失败:', error);
/** }
* 初始化服务,获取branchId }
* @returns {Promise<void>}
*/ async init() {
async init(projectId) { // Pre-fetch branchId for default project if set
if (projectId) { if (this.defaultProjectId) {
this.projectId = projectId; await this.getBranchId(this.defaultProjectId);
this.branchId = await this.getBranchId(); }
} }
}
/**
/** * 发送GET请求
* 发送GET请求 * @param {string} url - 请求的URL路径
* @param {string} url - 请求的URL路径 * @param {object} config - axios配置对象 (contains headers with projectId/branchId)
* @param {object} config - axios配置对象 * @returns {Promise} 返回响应数据中的data字段
* @returns {Promise} 返回响应数据中的data字段 */
*/ async get(url, config = {}) {
async get(url, config = {}) { try {
try { const response = await this.http.get(url, config);
const response = await this.http.get(url, { return response.data;
...config, } catch (error) {
headers: { // Enhance error logging if needed
...config.headers, throw error;
"X-Project-Id": this.projectId, }
"X-Branch-Id": this.branchId, }
},
}); /**
return response.data; * 发送POST请求
} catch (error) { * @param {string} url - 请求的URL路径
throw error; * @param {object} data - 请求体数据
} * @param {object} config - axios配置对象
} * @returns {Promise} 返回响应数据中的data字段
*/
/** async post(url, data = {}, config = {}) {
* 发送POST请求 try {
* @param {string} url - 请求的URL路径 const response = await this.http.post(url, data, config);
* @param {object} data - 请求体数据 return response.data;
* @param {object} config - axios配置对象 } catch (error) {
* @returns {Promise} 返回响应数据中的data字段 throw error;
*/ }
async post(url, data = {}, config = {}) { }
try {
const response = await this.http.post(url, data, { async getRequestConfig(projectId) {
...config, const pid = projectId || this.defaultProjectId;
headers: { if (!pid) throw new Error("No Project ID provided");
...config.headers,
"X-Project-Id": this.projectId, const branchId = await this.getBranchId(pid);
"X-Branch-Id": this.branchId, return {
}, headers: {
}); "X-Project-Id": pid,
return response.data; "X-Branch-Id": branchId,
} catch (error) { }
throw error; };
} }
}
async getBranchId(projectId) {
async getBranchId() { if (this.projectBranchIds.has(projectId)) {
const branches = await this.get(`/projects/${this.projectId}/api-versions`); return this.projectBranchIds.get(projectId);
return branches.data[0].id; }
}
// We need to fetch branch ID.
async getApiTreeList() { // BUT fetching branch ID requires Project ID header usually?
const apiTree = await this.get(`/projects/${this.projectId}/api-tree-list`); // Let's check the original implementation:
this.apiTreeList = apiTree; // const branches = await this.get(`/projects/${this.projectId}/api-versions`);
return apiTree; // It used `this.http` which had `this.projectId` in headers?
} // WAIT, original `get` implementation injected headers dynamically based on `this.projectId`.
async getFolderList() { // So here we must manually construct headers for this specific request to avoid circular dependency
// 定义缓存键 // or just pass X-Project-Id.
const cacheKey = 'folderList';
try {
// 检查缓存是否有效 const response = await this.http.get(`/projects/${projectId}/api-versions`, {
const cachedData = this.cache.get(cacheKey); headers: {
if (cachedData) { "X-Project-Id": projectId
return cachedData; }
} });
const {data: folderList} = await this.get(`/projects/${this.projectId}/api-detail-folders`); const branchId = response.data.data[0].id;
this.projectBranchIds.set(projectId, branchId);
// 设置缓存 return branchId;
this.cache.set(cacheKey, folderList); } catch (error) {
return folderList; console.error(`Failed to get branch ID for project ${projectId}`, error);
} throw error;
}
async getFolderByName(folderName) { }
const folderList = await this.getFolderList();
const folder = folderList.find((item) => item.name === folderName); async getApiTreeList(projectId) {
if (!folder) { const pid = projectId || this.defaultProjectId;
throw new Error(`Folder with name ${folderName} not found`); const cacheKey = `${pid}:apiTreeList`;
}
return folder; const cachedData = this.cache.get(cacheKey);
} if (cachedData) {
async getDataSchema() { return cachedData;
// 定义缓存键 }
const cacheKey = 'dataSchema';
const config = await this.getRequestConfig(pid);
// 检查缓存是否有效 const apiTree = await this.get(`/projects/${pid}/api-tree-list`, config);
const cachedData = this.cache.get(cacheKey);
if (cachedData) { this.cache.set(cacheKey, apiTree);
return cachedData; return apiTree;
} }
const {data:schemasTreeList} = await this.get(`/projects/${this.projectId}/data-schemas?locale=zh-CN`); async getFolderList(projectId) {
const pid = projectId || this.defaultProjectId;
// 设置缓存 // 定义缓存键
this.cache.set(cacheKey, schemasTreeList); const cacheKey = `${pid}:folderList`;
return schemasTreeList;
} // 检查缓存是否有效
const cachedData = this.cache.get(cacheKey);
async getApiDetails() { if (cachedData) {
// 定义缓存键 return cachedData;
const cacheKey = 'apiDetails'; }
// 检查缓存是否有效 const config = await this.getRequestConfig(pid);
const cachedData = this.cache.get(cacheKey); const {data: folderList} = await this.get(`/projects/${pid}/api-detail-folders`, config);
if (cachedData) {
return cachedData; // 设置缓存
} this.cache.set(cacheKey, folderList);
return folderList;
const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, {}); }
// 设置缓存 async getFolderByName(folderName, projectId) {
this.cache.set(cacheKey, apiDetails); const folderList = await this.getFolderList(projectId);
return apiDetails; const folder = folderList.find((item) => item.name === folderName);
} if (!folder) {
throw new Error(`Folder with name ${folderName} not found`);
getSchemaRefId(obj) { }
if (!obj || !obj.$ref) { return folder;
return; }
} async getDataSchema(projectId) {
const schemaName = obj.$ref.split('/').pop(); const pid = projectId || this.defaultProjectId;
return schemaName; // 定义缓存键
} const cacheKey = `${pid}:dataSchema`;
/** // 检查缓存是否有效
* 根据schema ID获取完整的schema定义,递归替换所有$ref引用 const cachedData = this.cache.get(cacheKey);
* @param {number|string} schemaId - schema的ID if (cachedData) {
* @param {Array} allSchemas - 所有schema数据数组(可选,用于递归) return cachedData;
* @param {Set} visitedIds - 已访问的ID集合,用于防止循环引用 }
* @returns {Promise<object>} 返回完整解析后的schema对象
*/ const config = await this.getRequestConfig(pid);
async getSchemaById(schemaId, allSchemas = null, visitedIds = new Set()) { const {data:schemasTreeList} = await this.get(`/projects/${pid}/data-schemas?locale=zh-CN`, config);
if (!schemaId) {
return null; // 设置缓存
} this.cache.set(cacheKey, schemasTreeList);
// 如果没有传入allSchemas,则获取所有schema数据 return schemasTreeList;
if (!allSchemas) { }
allSchemas = await this.getDataSchema();
} async getApiDetails(projectId) {
const pid = projectId || this.defaultProjectId;
// 查找对应ID的schema // 定义缓存键
const schema = allSchemas.find((item) => item.id.toString() === schemaId.toString()); const cacheKey = `${pid}:apiDetails`;
if (!schema) {
throw new Error(`Schema with id ${schemaId} not found`); // 检查缓存是否有效
} const cachedData = this.cache.get(cacheKey);
if (cachedData) {
// 防止循环引用 return cachedData;
if (visitedIds.has(schemaId.toString())) { }
return { type: 'object', description: `循环引用: ${schema.name}` };
} const config = await this.getRequestConfig(pid);
visitedIds.add(schemaId.toString()); const {data:apiDetails} = await this.get(`/api-details?locale=zh-CN`, config);
// 深拷贝schema以避免修改原始数据 // 设置缓存
const resolvedSchema = JSON.parse(JSON.stringify(schema)); this.cache.set(cacheKey, apiDetails);
return apiDetails;
// 递归替换jsonSchema中的所有$ref }
resolvedSchema.jsonSchema = await this.resolveRefs(
resolvedSchema.jsonSchema, getSchemaRefId(obj) {
allSchemas, if (!obj || !obj.$ref) {
new Set(visitedIds) return;
); }
const schemaName = obj.$ref.split('/').pop();
// 移除不需要的字段 return schemaName;
const { description, folderId, projectId, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema; }
return cleanSchema; /**
} * 根据schema ID获取完整的schema定义,递归替换所有$ref引用
* @param {number|string} schemaId - schema的ID
/** * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds }
* 递归解析对象中的所有$ref引用 * @returns {Promise<object>} 返回完整解析后的schema对象
* @param {object} obj - 要解析的对象 */
* @param {Array} allSchemas - 所有schema数据数组 async getSchemaById(schemaId, context = {}) {
* @param {Set} visitedIds - 已访问的ID集合 if (!schemaId) {
* @returns {Promise<object>} 返回解析后的对象 return null;
*/ }
async resolveRefs(obj, allSchemas, visitedIds) {
if (!obj || typeof obj !== 'object') { let { projectId, allSchemas, visitedIds } = context;
return obj; visitedIds = visitedIds || new Set();
}
// 如果没有传入allSchemas,则获取所有schema数据
// 如果是数组,递归处理每个元素 if (!allSchemas) {
if (Array.isArray(obj)) { allSchemas = await this.getDataSchema(projectId);
const resolved = []; context.allSchemas = allSchemas; // Update context for recursion
for (const item of obj) { }
resolved.push(await this.resolveRefs(item, allSchemas, visitedIds));
} // 查找对应ID的schema
return resolved; const schema = allSchemas.find((item) => item.id.toString() === schemaId.toString());
} if (!schema) {
throw new Error(`Schema with id ${schemaId} not found`);
// 如果对象包含$ref,替换为实际的schema定义 }
if (obj.$ref) {
const refId = this.getSchemaRefId(obj); // 防止循环引用
const referencedSchema = await this.getSchemaById(refId, allSchemas, visitedIds); if (visitedIds.has(schemaId.toString())) {
// 返回引用的jsonSchema内容 return { type: 'object', description: `循环引用: ${schema.name}` };
return referencedSchema.jsonSchema; }
} 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)
const resolved = {}; // 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.
for (const [key, value] of Object.entries(obj)) { // However, existing code passed `new Set(visitedIds)` to resolveRefs.
resolved[key] = await this.resolveRefs(value, allSchemas, visitedIds);
} // 深拷贝schema以避免修改原始数据
return resolved; const resolvedSchema = JSON.parse(JSON.stringify(schema));
}
// 递归替换jsonSchema中的所有$ref
async getApiDetailResp(apiDetail) { resolvedSchema.jsonSchema = await this.resolveRefs(
const dataSchema = await this.getDataSchema(); resolvedSchema.jsonSchema,
const requestSchemaId = this.getSchemaRefId(apiDetail.requestBody.jsonSchema); { ...context, visitedIds: new Set(visitedIds) }
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; // 移除不需要的字段
const { description, folderId, projectId: pid, moduleId, ordering, creatorId, editorId, createdAt, updatedAt, visibility, ...cleanSchema } = resolvedSchema;
let respDataSchemaId;
const isArray = responseDataSchema.type === 'array'; return cleanSchema;
// 处理数组和对象两种情况 }
if (isArray && responseDataSchema.items && responseDataSchema.items.$ref) {
// 数组情况:从 items.$ref 中提取 schema ID /**
respDataSchemaId = this.getSchemaRefId(responseDataSchema.items); * 递归解析对象中的所有$ref引用
} else if (responseDataSchema.$ref) { * @param {object} obj - 要解析的对象
// 对象情况:直接从 $ref 中提取 schema ID * @param {object} context - 上下文对象 { projectId, allSchemas, visitedIds }
respDataSchemaId = this.getSchemaRefId(responseDataSchema); * @returns {Promise<object>} 返回解析后的对象
} else { */
// 如果都没有 $ref,记录警告并返回 null async resolveRefs(obj, context) {
this.logToFile('WARN', `无法从 responseDataSchema 中提取 schema ID`, responseDataSchema); if (!obj || typeof obj !== 'object') {
respDataSchemaId = null; return obj;
} }
return { // 如果是数组,递归处理每个元素
path: apiDetail.path, if (Array.isArray(obj)) {
name: apiDetail.name, const resolved = [];
method: apiDetail.method, for (const item of obj) {
url: apiDetail.url, resolved.push(await this.resolveRefs(item, context));
requestSchema: await this.getSchemaById(requestSchemaId), }
responseSchema: isArray ? { return resolved;
type: 'array', }
items: respDataSchemaId ? await this.getSchemaById(respDataSchemaId) : null,
} : await this.getSchemaById(respDataSchemaId), // 如果对象包含$ref,替换为实际的schema定义
}; if (obj.$ref) {
} const refId = this.getSchemaRefId(obj);
// Calls getSchemaById with context
async getApiDetailByPath(path, projectId) { const referencedSchema = await this.getSchemaById(refId, context);
if (projectId) { // 返回引用的jsonSchema内容
await this.init(projectId); return referencedSchema.jsonSchema;
} }
const apiDetails = await this.getApiDetails();
const apiDetail = apiDetails.find((item) => item.path === path); // 递归处理对象的所有属性
if (!apiDetail) { const resolved = {};
throw new Error(`API with path ${path} not found`); for (const [key, value] of Object.entries(obj)) {
} resolved[key] = await this.resolveRefs(value, context);
return this.getApiDetailResp(apiDetail); }
} return resolved;
}
async getApiDetailsById(apiId) {
const apiDetails = await this.getApiDetails(); async getApiDetailResp(apiDetail, projectId) {
const apiDetail = apiDetails.find((item) => item.id === apiId); const pid = projectId || this.defaultProjectId;
if (!apiDetail) { const dataSchema = await this.getDataSchema(pid);
throw new Error(`API with id ${id} not found`); const context = { projectId: pid, allSchemas: dataSchema };
}
return this.getApiDetailResp(apiDetail); 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);
// TODO: // Find the response data schema property
async getApiListByFolderName(folderName, projectId) { let responseDataSchema;
if (projectId) { try {
await this.init(projectId); const schema = dataSchema.find((item) => item.id.toString() === responseSchemaId);
} if (schema && schema.jsonSchema && schema.jsonSchema.properties) {
const folder = await this.getFolderByName(folderName); responseDataSchema = schema.jsonSchema.properties.data;
const apiDetails = await this.getApiDetails(); }
const apiList = apiDetails.filter((item) => item.folderId === folder.id); } catch (e) {
return Promise.all(apiList.map((api) => this.getApiDetailResp(api))); // 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)));
}
}
+195 -233
View File
@@ -1,234 +1,196 @@
#!/usr/bin/env node #!/usr/bin/env node
// 引入必要的模块 // 引入必要的模块
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import http from "http"; import http from "http";
import { z } from "zod"; import { z } from "zod";
import { ApifoxService } from './apifox.js' import { AsyncLocalStorage } from 'async_hooks';
import { ApifoxService } from './apifox.js'
// 创建 MCP 服务器实例
// 为其设置一个名字和版本 const requestContext = new AsyncLocalStorage();
const server = new McpServer(
{ // 创建 MCP 服务器实例
name: "apifox-mcp-server", // 为其设置一个名字和版本
version: "1.0.0", const server = new McpServer(
}, {
{ name: "apifox-mcp-server",
capabilities: { version: "1.0.0",
prompts: {}, },
tools: {}, {
}, capabilities: {
} prompts: {},
); tools: {},
},
}
const apifoxService = new ApifoxService({ );
projectId: Number(process.env.APIFOX_PROJECT_ID),
token: process.env.APIFOX_TOKEN 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);
server.registerTool(
'get_api_by_path', if (!token) {
{ throw new Error("Apifox Token not configured (env: APIFOX_TOKEN or header: Authorization)");
description: "根据接口path获取Apifox接口定义数据", }
inputSchema: z.object({
path: z.string().describe("接口路径"), return new ApifoxService({
projectId: z.number().optional().describe("Apifox项目ID"), projectId,
}), token
}, });
}
async ({ path, projectId }) => {
try { server.registerTool(
'get_api_by_path',
const apiData = await apifoxService.getApiDetailByPath(path, projectId); {
description: "根据接口path获取Apifox接口定义数据",
return { inputSchema: z.object({
content: [ path: z.string().describe("接口路径"),
{ projectId: z.number().optional().describe("Apifox项目ID"),
type: "text", }),
text: JSON.stringify(apiData, null, 2), },
},
], async ({ path, projectId }) => {
}; try {
} catch (error) { const apifoxService = getApifoxService();
return { const apiData = await apifoxService.getApiDetailByPath(path, projectId);
content: [
{ return {
type: "text", content: [
text: `获取接口详情失败: ${error.message}`, {
}, type: "text",
], text: JSON.stringify(apiData, null, 2),
isError: true, },
}; ],
} };
}
) } catch (error) {
server.registerTool( return {
'get_apis_by_folder', content: [
{ {
description: "根据接口目录获取Apifox目录下所有接口定义数据", type: "text",
inputSchema: z.object({ text: `获取接口详情失败: ${error.message}`,
folderName: z.string().describe("接口路径"), },
projectId: z.number().optional().describe("Apifox项目ID"), ],
}), isError: true,
}, };
}
async ({ folderName, projectId }) => { }
try { )
server.registerTool(
const apiData = await apifoxService.getApiListByFolderName(folderName, projectId); 'get_apis_by_folder',
{
return { description: "根据接口目录获取Apifox目录下所有接口定义数据",
content: [ inputSchema: z.object({
{ folderName: z.string().describe("接口路径"),
type: "text", projectId: z.number().optional().describe("Apifox项目ID"),
text: JSON.stringify(apiData, null, 2), }),
}, },
],
}; async ({ folderName, projectId }) => {
try {
} catch (error) {
return { const apifoxService = getApifoxService();
content: [ const apiData = await apifoxService.getApiListByFolderName(folderName, projectId);
{
type: "text", return {
text: `获取接口数据失败: ${error.message}`, content: [
}, {
], type: "text",
isError: true, text: JSON.stringify(apiData, null, 2),
}; },
} ],
} };
)
} catch (error) {
return {
content: [
// 定义 prompts {
server.registerPrompt( type: "text",
'gen-api-by-path', text: `获取接口数据失败: ${error.message}`,
{ },
title: 'Generate API by Path', ],
description: '根据输入的接口路径获取Apifox接口文档定义,然后根据定义生成 api 接口代码定义', isError: true,
}, };
() => ({ }
messages: [ }
{ )
role: "user",
content: {
type: "text", // 启动服务器,使用标准输入/输出(stdio)与客户端通信
text: `请根据以下步骤获取Apifox的接口定义并生成 api 定义代码。 async function main() {
const args = process.argv.slice(2);
步骤: const transportArgIndex = args.indexOf('--transport') !== -1 ? args.indexOf('--transport') : args.indexOf('-t');
1. 使用 get_api_by_path 工具获取对应的接口定义schema const transportType = transportArgIndex !== -1 ? args[transportArgIndex + 1] : 'stdio';
2. 根据返回的 path,method, requestSchema, responseSchema 和其他必要字段,生成 api 定义代码
3. 接口的参数和返回值的类型定义,可以分别参考 requestSchema 和 responseSchema 里面的jsonSchema定义,可以根据description要补充相关的注释 const portArgIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p');
4. 所有字段先定义为可空类型 const port = portArgIndex !== -1 ? parseInt(args[portArgIndex + 1]) : 8889;
5. 请根据当前项目的项目接口和代码风格生成 api 接口代码
请开始执行这些步骤。`,
}, if (transportType === 'http') {
}, const transport = new StreamableHTTPServerTransport({});
], await server.connect(transport);
})
); const httpServer = http.createServer(async (req, res) => {
// 设置 CORS 头
server.registerPrompt( res.setHeader('Access-Control-Allow-Origin', '*');
'gen-api-by-folder', res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
{ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id');
title: 'Generate API by Folder',
description: '根据输入的接口文件夹获取Apifox相关文件夹下所有的接口文档定义,然后根据定义生成 api 接口代码定义', if (req.method === 'OPTIONS') {
}, res.writeHead(204);
() => ({ res.end();
messages: [ return;
{ }
role: "user",
content: { if (req.method === 'GET' && req.url === '/health') {
type: "text", res.writeHead(200, { 'Content-Type': 'application/json' });
text: `请根据以下步骤获取Apifox的接口定义并生成 api 定义代码。 res.end(JSON.stringify({ status: 'ok' }));
return;
步骤: }
1. 使用 get_api_by_folder 工具获取对应的接口定义schema,可能会返回多条接口的数据
2. 根据返回的 path,method, requestSchema, responseSchema 和其他必要字段,生成 api 定义代码 const authHeader = req.headers['authorization'];
3. 接口的参数和返回值的类型定义,可以分别参考 requestSchema 和 responseSchema 里面的jsonSchema定义,可以根据description要补充相关的注释 const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
4. 所有字段先定义为可空类型 const projectId = req.headers['x-apifox-project-id'];
5. 请根据当前项目的项目接口和代码风格生成 api 接口代码
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) {
// 启动服务器,使用标准输入/输出(stdio)与客户端通信 res.writeHead(500);
res.end("Internal Server Error");
// 启动服务器,使用标准输入/输出(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';
httpServer.listen(port, () => {
const portArgIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p'); console.error(`Apifox MCP 服务器已通过 http 启动,监听端口 ${port}`);
const port = portArgIndex !== -1 ? parseInt(args[portArgIndex + 1]) : 8889; console.error(`MCP 服务 endpoint: http://localhost:${port}/sse`);
console.error(`MCP 消息 endpoint: http://localhost:${port}/messages`);
});
} else {
if (transportType === 'http') {
const transport = new StreamableHTTPServerTransport({}); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
console.error("Apifox MCP 服务器已通过 stdio 启动"); // 日志输出到 stderr
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') { main().catch((error) => {
res.writeHead(204); console.error("服务器运行出现致命错误:", error);
res.end(); process.exit(1);
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);
});
export { server }; export { server };
+29 -29
View File
@@ -1,29 +1,29 @@
{ {
"name": "apifox-mcp-server", "name": "apifox-mcp-server",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"dev": "npx @modelcontextprotocol/inspector node index.js" "dev": "npx @modelcontextprotocol/inspector node index.js"
}, },
"keywords": [ "keywords": [
"mcp", "mcp",
"apifox" "apifox"
], ],
"author": "Jasery", "author": "Jasery",
"license": "ISC", "license": "ISC",
"description": "A Model Context Protocol server for Apifox", "description": "A Model Context Protocol server for Apifox",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.22.0", "@modelcontextprotocol/sdk": "^1.22.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"bin": { "bin": {
"apifox-mcp-server": "./index.js" "apifox-mcp-server": "./index.js"
}, },
"devDependencies": { "devDependencies": {
"@modelcontextprotocol/inspector": "^0.17.2" "@modelcontextprotocol/inspector": "^0.17.2"
} }
} }