feat: 完善 MCP 接入体验与协议层稳定性

接入体验:
- 客户端选择器覆盖 7 个客户端(Claude Desktop/Code、Cursor、Cline、
  Windsurf、Codex、GitHub Copilot),自动生成对应配置
- API Key 一键含 key 复制(密码验证后写入剪贴板)
- 公开 /mcp/:projectId/info + Test connection 按钮
- 项目新增 Usage 标签(每日柱状图 + 工具调用分布)
- 导入完成后引导跳转到 MCP 配置

协议层稳定性:
- session 超时清理(5 分钟扫描 / 30 分钟 idle 关闭)
- 错误响应标准化为 { error: { code, message } }
- 调用日志捕获错误信息 + 4KB payload 截断
- McpCallLog 增加 errorMessage 字段(migration)

抽出可复用模块:PasswordRevealPrompt 组件、BarChart 组件、
trends helper(daysWindowStart + getDailyTrends)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 12:52:47 +08:00
parent 8d7692a42d
commit 95198e6a07
22 changed files with 851 additions and 241 deletions

View File

@@ -0,0 +1,61 @@
import { prisma, Prisma } from '@agent-fox/shared';
export type TrendRow = {
date: string;
total: bigint;
success_count: bigint;
avg_duration: number;
tokens: bigint;
};
export function daysWindowStart(days: number): Date {
const since = new Date();
since.setDate(since.getDate() - days);
since.setHours(0, 0, 0, 0);
return since;
}
/**
* Daily aggregation of MCP call logs in `[since, now]`. Pass `projectId`
* to scope; omit for site-wide. Always emits one row per day in range
* (zeros for days with no calls), in chronological order.
*/
export async function getDailyTrends(opts: { since: Date; days: number; projectId?: string }) {
const { since, days, projectId } = opts;
const projectFilter = projectId
? Prisma.sql`AND "projectId" = ${projectId}`
: Prisma.empty;
const rows = await prisma.$queryRaw<TrendRow[]>`
SELECT
TO_CHAR("calledAt", 'YYYY-MM-DD') AS date,
COUNT(*)::bigint AS total,
SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count,
COALESCE(AVG("durationMs"), 0)::int AS avg_duration,
COALESCE(SUM("estimatedTokens"), 0)::bigint AS tokens
FROM "McpCallLog"
WHERE "calledAt" >= ${since} ${projectFilter}
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
ORDER BY date
`;
const map = new Map(rows.map((r) => [r.date, r]));
const filled = [];
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setDate(d.getDate() + i);
const key = d.toISOString().slice(0, 10);
const row = map.get(key);
const total = row ? Number(row.total) : 0;
const successCnt = row ? Number(row.success_count) : 0;
filled.push({
date: key,
total,
success: successCnt,
errors: total - successCnt,
avgDuration: row ? row.avg_duration : 0,
tokens: row ? Number(row.tokens) : 0,
});
}
return filled;
}

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from '../middleware/admin.js';
import { daysWindowStart, getDailyTrends } from '../lib/trends.js';
const router: RouterType = Router();
@@ -86,42 +87,14 @@ router.get('/stats', async (_req, res) => {
router.get('/stats/trends', async (req, res) => {
const days = req.query.days === '30' ? 30 : 7;
const since = new Date();
since.setDate(since.getDate() - days);
since.setHours(0, 0, 0, 0);
const rows = await prisma.$queryRaw<
{ date: string; total: bigint; success_count: bigint; avg_duration: number }[]
>`
SELECT
TO_CHAR("calledAt", 'YYYY-MM-DD') AS date,
COUNT(*)::bigint AS total,
SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count,
COALESCE(AVG("durationMs"), 0)::int AS avg_duration
FROM "McpCallLog"
WHERE "calledAt" >= ${since}
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
ORDER BY date
`;
// Build full date range with zeros for missing days
const dataMap = new Map(rows.map(r => [r.date, r]));
const trends = [];
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setDate(d.getDate() + i);
const key = d.toISOString().slice(0, 10);
const row = dataMap.get(key);
const total = row ? Number(row.total) : 0;
const successCnt = row ? Number(row.success_count) : 0;
trends.push({
date: key,
calls: total,
successRate: total > 0 ? Math.round((successCnt / total) * 100) : 100,
avgDuration: row ? row.avg_duration : 0,
});
}
const since = daysWindowStart(days);
const filled = await getDailyTrends({ since, days });
const trends = filled.map((d) => ({
date: d.date,
calls: d.total,
successRate: d.total > 0 ? Math.round((d.success / d.total) * 100) : 100,
avgDuration: d.avgDuration,
}));
res.json({ success: true, data: trends });
});

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { parseOpenApiDocument } from '../services/openapi-parser.js';
import { daysWindowStart, getDailyTrends } from '../lib/trends.js';
const router: RouterType = Router();
router.use(requireAuth);
@@ -121,6 +122,51 @@ router.put('/:id', async (req, res) => {
res.json({ success: true, data: updated });
});
router.get('/:id/usage', async (req, res) => {
const days = req.query.days === '30' ? 30 : 7;
const owned = await prisma.project.findFirst({
where: { id: req.params.id, userId: req.user!.userId },
select: { id: true },
});
if (!owned) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
return;
}
const since = daysWindowStart(days);
const [trends, toolRows] = await Promise.all([
getDailyTrends({ since, days, projectId: req.params.id }),
prisma.mcpCallLog.groupBy({
by: ['toolName'],
where: { projectId: req.params.id, calledAt: { gte: since } },
_count: { _all: true },
_sum: { estimatedTokens: true },
orderBy: { _count: { toolName: 'desc' } },
}),
]);
const daily = trends.map((d) => ({ date: d.date, calls: d.total, errors: d.errors, tokens: d.tokens }));
const totals = daily.reduce(
(acc, d) => ({ calls: acc.calls + d.calls, errors: acc.errors + d.errors, tokens: acc.tokens + d.tokens }),
{ calls: 0, errors: 0, tokens: 0 },
);
res.json({
success: true,
data: {
days,
totals,
daily,
byTool: toolRows.map((r) => ({
toolName: r.toolName,
calls: r._count._all,
tokens: r._sum.estimatedTokens ?? 0,
})),
},
});
});
router.delete('/:id', async (req, res) => {
const result = await prisma.project.deleteMany({
where: { id: req.params.id, userId: req.user!.userId },