feat: 添加 Admin 管理后台

- 数据库新增 Role 枚举、disabled 字段和 McpCallLog 调用日志表
- 后端新增 requireAdmin 中间件和 /api/admin/* 管理接口(统计、用户、项目、日志)
- MCP 工具调用自动记录详细日志(耗时、参数、响应大小、客户端IP、token估算)
- 前端新增 /admin 路由区域:仪表盘、用户管理、项目管理、调用日志四个页面
- JWT 携带 role 字段,登录/OAuth 增加禁用账号检查
- nginx 配置补充 X-Forwarded-For 透传真实客户端 IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:04:44 +08:00
parent d45cc45815
commit 6fe04f4893
25 changed files with 1847 additions and 20 deletions

View File

@@ -40,7 +40,9 @@ app.post('/mcp/:projectId', mcpAuth, async (req, res) => {
}
};
const server = createMcpServer(projectId);
const forwarded = req.headers['x-forwarded-for'] as string | undefined;
const clientIp = forwarded?.split(',')[0].trim() || req.socket.remoteAddress || '';
const server = createMcpServer(projectId, clientIp);
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});

View File

@@ -0,0 +1,45 @@
import { prisma } from '@agent-fox/shared';
type CallContext = {
projectId: string;
toolName: string;
requestParams: Record<string, unknown>;
clientIp: string;
};
export async function logMcpCall(ctx: CallContext, fn: () => Promise<any>): Promise<any> {
const start = Date.now();
let success = true;
let result: any;
try {
result = await fn();
if (result?.isError) success = false;
return result;
} catch (err) {
success = false;
throw err;
} finally {
const durationMs = Date.now() - start;
const responseText = result ? JSON.stringify(result) : '';
const responseSize = Buffer.byteLength(responseText, 'utf-8');
// Rough token estimate: ~4 chars per token
const estimatedTokens = Math.ceil(responseText.length / 4);
// Fire-and-forget: don't block the response
prisma.mcpCallLog.create({
data: {
projectId: ctx.projectId,
toolName: ctx.toolName,
durationMs,
success,
requestParams: ctx.requestParams as any,
responseSize,
clientIp: ctx.clientIp,
estimatedTokens,
},
}).catch((err) => {
console.error('Failed to log MCP call:', err);
});
}
}

View File

@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
import { listEndpoints } from './tools/list-endpoints.js';
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
import { searchEndpoints } from './tools/search-endpoints.js';
import { logMcpCall } from './lib/call-logger.js';
export function createMcpServer(projectId: string): McpServer {
export function createMcpServer(projectId: string, clientIp: string = ''): McpServer {
const server = new McpServer({
name: 'agent-fox',
version: '0.1.0',
});
const ctx = (toolName: string, requestParams: Record<string, unknown> = {}) =>
({ projectId, toolName, requestParams, clientIp });
server.tool(
'get_project_overview',
'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
{},
async () => getProjectOverview(projectId),
async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
);
server.tool(
'list_modules',
'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
{},
async () => listModules(projectId),
async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
);
server.tool(
'list_endpoints',
'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
{ moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') },
async ({ moduleId }) => listEndpoints(projectId, moduleId),
async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
);
server.tool(
'get_endpoint_detail',
'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.',
{ endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') },
async ({ endpointId }) => getEndpointDetail(projectId, endpointId),
async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
);
server.tool(
@@ -47,7 +51,7 @@ export function createMcpServer(projectId: string): McpServer {
keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
},
async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId),
async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)),
);
return server;