From 95198e6a0775e1c3274f36a156906afd8e68f8c8 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Sat, 2 May 2026 12:52:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20MCP=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=8D=8F=E8=AE=AE=E5=B1=82?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入体验: - 客户端选择器覆盖 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) --- packages/mcp/src/auth.ts | 11 +- packages/mcp/src/index.ts | 99 +++++++-- packages/mcp/src/lib/call-logger.ts | 42 +++- packages/mcp/src/lib/errors.ts | 12 ++ packages/server/src/lib/trends.ts | 61 ++++++ packages/server/src/routes/admin.ts | 45 +--- packages/server/src/routes/projects.ts | 46 ++++ packages/shared/src/db.ts | 1 + packages/shared/src/index.ts | 2 +- packages/web/src/components/BarChart.tsx | 45 ++++ .../src/components/PasswordRevealPrompt.tsx | 76 +++++++ .../web/src/components/SettingsDialog.tsx | 107 +++------- packages/web/src/lib/i18n/en.ts | 30 ++- packages/web/src/lib/i18n/zh.ts | 30 ++- packages/web/src/lib/mcp-clients.ts | 96 +++++++++ packages/web/src/pages/ImportDialog.tsx | 10 +- packages/web/src/pages/ProjectDetail.tsx | 3 + packages/web/src/pages/admin/Dashboard.tsx | 49 ++--- .../web/src/pages/tabs/McpIntegration.tsx | 199 ++++++++++++++---- packages/web/src/pages/tabs/Usage.tsx | 125 +++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 22 files changed, 851 insertions(+), 241 deletions(-) create mode 100644 packages/mcp/src/lib/errors.ts create mode 100644 packages/server/src/lib/trends.ts create mode 100644 packages/web/src/components/BarChart.tsx create mode 100644 packages/web/src/components/PasswordRevealPrompt.tsx create mode 100644 packages/web/src/lib/mcp-clients.ts create mode 100644 packages/web/src/pages/tabs/Usage.tsx create mode 100644 prisma/migrations/20260502120000_mcp_call_log_error_message/migration.sql diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 5db4138..67bf2d2 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -1,13 +1,14 @@ import type { Request, Response, NextFunction } from 'express'; import bcrypt from 'bcrypt'; import { prisma } from '@agent-fox/shared'; +import { sendError } from './lib/errors.js'; export async function mcpAuth(req: Request, res: Response, next: NextFunction): Promise { const projectId = req.params['projectId'] as string; const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { - res.status(401).json({ error: 'Missing API key' }); + sendError(res, 401, 'MISSING_API_KEY', 'Missing API key. Send Authorization: Bearer .'); return; } @@ -21,25 +22,23 @@ export async function mcpAuth(req: Request, res: Response, next: NextFunction): }); if (!user || !user.apiKeyHash) { - res.status(401).json({ error: 'Invalid API key' }); + sendError(res, 401, 'INVALID_API_KEY', 'Invalid API key.'); return; } - // Verify API key with bcrypt const valid = await bcrypt.compare(apiKey, user.apiKeyHash); if (!valid) { - res.status(401).json({ error: 'Invalid API key' }); + sendError(res, 401, 'INVALID_API_KEY', 'Invalid API key.'); return; } - // Verify user owns the project const project = await prisma.project.findFirst({ where: { id: projectId, userId: user.id }, select: { id: true }, }); if (!project) { - res.status(404).json({ error: 'Project not found' }); + sendError(res, 404, 'PROJECT_NOT_FOUND', 'Project not found or you do not have access to it.'); return; } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 57efcc0..5e69953 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,9 +1,15 @@ import { randomUUID } from 'node:crypto'; import express from 'express'; import cors from 'cors'; +import { prisma } from '@agent-fox/shared'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { mcpAuth } from './auth.js'; import { createMcpServer } from './server.js'; +import { sendError } from './lib/errors.js'; + +const TOOL_COUNT = 5; +const SESSION_IDLE_MS = 30 * 60 * 1000; +const SESSION_SWEEP_MS = 5 * 60 * 1000; const app = express(); app.use(cors()); @@ -13,31 +19,63 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok' }); }); -// Session storage -const transports: Record = {}; +// Public probe for "Test connection" UI. Discloses project name + counts but +// not endpoint contents. Project IDs are cuids so enumeration is impractical; +// add rate-limiting if abused. +app.get('/mcp/:projectId/info', async (req, res) => { + const project = await prisma.project.findUnique({ + where: { id: req.params.projectId }, + select: { + name: true, openApiVersion: true, + _count: { select: { endpoints: true, modules: true } }, + }, + }); + if (!project) { + sendError(res, 404, 'PROJECT_NOT_FOUND', 'Project not found.'); + return; + } + res.json({ + projectName: project.name, + openApiVersion: project.openApiVersion, + totalEndpoints: project._count.endpoints, + totalModules: project._count.modules, + toolCount: TOOL_COUNT, + }); +}); + +type SessionEntry = { + transport: StreamableHTTPServerTransport; + lastActivityAt: number; +}; + +const sessions = new Map(); + +function touchSession(sessionId: string | undefined): void { + if (!sessionId) return; + const entry = sessions.get(sessionId); + if (entry) entry.lastActivityAt = Date.now(); +} -// MCP Streamable HTTP endpoint app.post('/mcp/:projectId', mcpAuth, async (req, res) => { const projectId = (req as any).projectId as string; const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res, req.body); + if (sessionId && sessions.has(sessionId)) { + touchSession(sessionId); + await sessions.get(sessionId)!.transport.handleRequest(req, res, req.body); + touchSession(sessionId); return; } - // New session — check for initialize request const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { - transports[id] = transport; + sessions.set(id, { transport, lastActivityAt: Date.now() }); }, }); transport.onclose = () => { - if (transport.sessionId) { - delete transports[transport.sessionId]; - } + if (transport.sessionId) sessions.delete(transport.sessionId); }; const forwarded = req.headers['x-forwarded-for'] as string | undefined; @@ -47,28 +85,45 @@ app.post('/mcp/:projectId', mcpAuth, async (req, res) => { await transport.handleRequest(req, res, req.body); }); -// SSE endpoint for session resumption app.get('/mcp/:projectId', mcpAuth, async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(400).json({ error: 'Invalid session. Start a new session via POST.' }); + const entry = sessionId ? sessions.get(sessionId) : undefined; + if (!entry) { + sendError(res, 400, 'INVALID_SESSION', 'Invalid session. Start a new session via POST.'); + return; } + touchSession(sessionId); + await entry.transport.handleRequest(req, res); + touchSession(sessionId); }); -// Session termination app.delete('/mcp/:projectId', mcpAuth, async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string; - if (sessionId && transports[sessionId]) { - await transports[sessionId].close(); - delete transports[sessionId]; - res.status(204).end(); - } else { - res.status(400).json({ error: 'Invalid session' }); + const entry = sessionId ? sessions.get(sessionId) : undefined; + if (!entry) { + sendError(res, 400, 'INVALID_SESSION', 'Invalid session.'); + return; } + await entry.transport.close(); + sessions.delete(sessionId); + res.status(204).end(); }); +// Reaper: close transports the client has abandoned. The MCP SDK's onclose +// only fires on graceful shutdown; without this, idle sessions accumulate and +// leak memory across days of uptime. +const sweeper = setInterval(() => { + const cutoff = Date.now() - SESSION_IDLE_MS; + for (const [id, entry] of sessions) { + if (entry.lastActivityAt >= cutoff) continue; + sessions.delete(id); + entry.transport.close().catch((err) => { + console.error(`Failed to close idle session ${id}:`, err); + }); + } +}, SESSION_SWEEP_MS); +sweeper.unref(); + const port = process.env.MCP_PORT || 3001; app.listen(port, () => { console.log(`MCP service running on port ${port}`); diff --git a/packages/mcp/src/lib/call-logger.ts b/packages/mcp/src/lib/call-logger.ts index ee1ebc7..ff8c85b 100644 --- a/packages/mcp/src/lib/call-logger.ts +++ b/packages/mcp/src/lib/call-logger.ts @@ -7,36 +7,68 @@ type CallContext = { clientIp: string; }; +const MAX_REQUEST_PARAMS_BYTES = 4 * 1024; +const MAX_ERROR_MESSAGE_LEN = 512; + +function truncateString(s: string, max: number): string { + return s.length > max ? `${s.slice(0, max - 1)}…` : s; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return '"[unserializable]"'; + } +} + +function clampRequestParams(params: Record): Record { + const json = safeStringify(params); + if (Buffer.byteLength(json, 'utf-8') <= MAX_REQUEST_PARAMS_BYTES) return params; + return { _truncated: true, _preview: truncateString(json, MAX_REQUEST_PARAMS_BYTES - 32) }; +} + +function extractToolErrorMessage(result: any): string | null { + if (!result?.isError) return null; + const text = result?.content?.[0]?.text; + if (typeof text !== 'string') return 'Tool returned isError without text'; + return truncateString(text, MAX_ERROR_MESSAGE_LEN); +} + export async function logMcpCall(ctx: CallContext, fn: () => Promise): Promise { const start = Date.now(); let success = true; + let errorMessage: string | null = null; let result: any; try { result = await fn(); - if (result?.isError) success = false; + if (result?.isError) { + success = false; + errorMessage = extractToolErrorMessage(result); + } return result; } catch (err) { success = false; + errorMessage = truncateString(err instanceof Error ? err.message : String(err), MAX_ERROR_MESSAGE_LEN); throw err; } finally { const durationMs = Date.now() - start; - const responseText = result ? JSON.stringify(result) : ''; + const responseText = result ? safeStringify(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, + requestParams: clampRequestParams(ctx.requestParams) as any, responseSize, clientIp: ctx.clientIp, estimatedTokens, + errorMessage, }, }).catch((err) => { console.error('Failed to log MCP call:', err); diff --git a/packages/mcp/src/lib/errors.ts b/packages/mcp/src/lib/errors.ts new file mode 100644 index 0000000..1a9bbcc --- /dev/null +++ b/packages/mcp/src/lib/errors.ts @@ -0,0 +1,12 @@ +import type { Response } from 'express'; + +export type McpErrorCode = + | 'MISSING_API_KEY' + | 'INVALID_API_KEY' + | 'PROJECT_NOT_FOUND' + | 'INVALID_SESSION' + | 'INTERNAL'; + +export function sendError(res: Response, status: number, code: McpErrorCode, message: string): void { + res.status(status).json({ error: { code, message } }); +} diff --git a/packages/server/src/lib/trends.ts b/packages/server/src/lib/trends.ts new file mode 100644 index 0000000..ec905bb --- /dev/null +++ b/packages/server/src/lib/trends.ts @@ -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` + 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; +} diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 61c78ca..0ce9a3e 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -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 }); }); diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts index aede30e..2cc9ed1 100644 --- a/packages/server/src/routes/projects.ts +++ b/packages/server/src/routes/projects.ts @@ -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 }, diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index 9b6c4ce..d47e71a 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -1,3 +1,4 @@ import { PrismaClient } from '@prisma/client'; export const prisma = new PrismaClient(); +export { Prisma } from '@prisma/client'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6274c8a..059c9c3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,2 @@ -export { prisma } from './db.js'; +export { prisma, Prisma } from './db.js'; export type * from './types.js'; diff --git a/packages/web/src/components/BarChart.tsx b/packages/web/src/components/BarChart.tsx new file mode 100644 index 0000000..246172b --- /dev/null +++ b/packages/web/src/components/BarChart.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; + +export type BarChartPoint = { + key: string; + label: string; + value: number; + /** Optional secondary value rendered as an overlay (e.g., errors stacked on calls). */ + secondary?: number; + tooltip: ReactNode; +}; + +export default function BarChart({ points, height = 180, emptyLabel }: { points: BarChartPoint[]; height?: number; emptyLabel: string }) { + if (points.length === 0) { + return
{emptyLabel}
; + } + + const max = Math.max(...points.map((p) => p.value), 1); + + return ( +
+ {points.map((point) => { + const barHeight = (point.value / max) * 100; + const overlayHeight = point.secondary !== undefined && point.value > 0 + ? (point.secondary / point.value) * barHeight + : 0; + return ( +
+
+
+ {point.tooltip} +
+
+
+
+ {overlayHeight > 0 && ( +
+ )} +
+ {point.label} +
+ ); + })} +
+ ); +} diff --git a/packages/web/src/components/PasswordRevealPrompt.tsx b/packages/web/src/components/PasswordRevealPrompt.tsx new file mode 100644 index 0000000..3777a95 --- /dev/null +++ b/packages/web/src/components/PasswordRevealPrompt.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { apiFetch } from '../lib/api'; +import { useI18n } from '../lib/i18n'; +import { useAuth } from '../lib/auth'; + +type Props = { + open: boolean; + promptText: string; + onCancel: () => void; + onReveal: (apiKey: string) => void; + onSetPasswordClick: () => void; +}; + +export default function PasswordRevealPrompt({ open, promptText, onCancel, onReveal, onSetPasswordClick }: Props) { + const { t } = useI18n(); + const { user } = useAuth(); + const hasPassword = user?.hasPassword !== false; + + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + if (!open) return null; + + const submit = async () => { + setLoading(true); + setError(''); + try { + const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', { + method: 'POST', body: JSON.stringify({ password }), + }); + onReveal(data.apiKey); + setPassword(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Verification failed'); + } finally { + setLoading(false); + } + }; + + if (!hasPassword) { + return ( +
+

{t('dashboard.settings.setPasswordToReveal')}

+
+ + +
+
+ ); + } + + return ( +
+

{promptText}

+ setPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && password) submit(); }} + className="input-base" + placeholder={t('dashboard.settings.currentPassword')} + autoFocus + /> + {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/packages/web/src/components/SettingsDialog.tsx b/packages/web/src/components/SettingsDialog.tsx index f5235b1..8959dec 100644 --- a/packages/web/src/components/SettingsDialog.tsx +++ b/packages/web/src/components/SettingsDialog.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../lib/auth'; import { useI18n } from '../lib/i18n'; import { apiFetch } from '../lib/api'; import ConfirmDialog from './ConfirmDialog'; +import PasswordRevealPrompt from './PasswordRevealPrompt'; type ApiKeyStatus = { hasKey: boolean; prefix: string | null }; @@ -37,10 +38,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo const [keyError, setKeyError] = useState(''); const [keyCopied, setKeyCopied] = useState(false); const [showRotateConfirm, setShowRotateConfirm] = useState(false); - const [showPasswordPrompt, setShowPasswordPrompt] = useState<'reveal' | 'copy' | null>(null); - const [verifyPassword, setVerifyPassword] = useState(''); - const [verifyError, setVerifyError] = useState(''); - const [verifyLoading, setVerifyLoading] = useState(false); + const [passwordPromptMode, setPasswordPromptMode] = useState<'reveal' | 'copy' | null>(null); useEffect(() => { const el = dialogRef.current; @@ -61,9 +59,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo setRevealedKey(null); setKeyError(''); setKeyCopied(false); - setShowPasswordPrompt(null); - setVerifyPassword(''); - setVerifyError(''); + setPasswordPromptMode(null); } }, [open, user?.name]); @@ -164,27 +160,15 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo const hasPassword = user?.hasPassword !== false; - const handleVerifyAndAction = async () => { - setVerifyLoading(true); - setVerifyError(''); - try { - const data = await apiFetch<{ apiKey: string }>('/auth/api-key/reveal', { - method: 'POST', body: JSON.stringify({ password: verifyPassword }), - }); - if (showPasswordPrompt === 'copy') { - navigator.clipboard.writeText(data.apiKey); - setKeyCopied(true); - setTimeout(() => setKeyCopied(false), 2000); - } else { - setRevealedKey(data.apiKey); - } - setShowPasswordPrompt(null); - setVerifyPassword(''); - } catch (err) { - setVerifyError(err instanceof Error ? err.message : 'Verification failed'); - } finally { - setVerifyLoading(false); + const handleReveal = (apiKey: string) => { + if (passwordPromptMode === 'copy') { + navigator.clipboard.writeText(apiKey); + setKeyCopied(true); + setTimeout(() => setKeyCopied(false), 2000); + } else { + setRevealedKey(apiKey); } + setPasswordPromptMode(null); }; const copyFreshKey = () => { @@ -298,8 +282,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo {/* Reveal button */}
- {showPasswordPrompt && ( -
- {hasPassword ? ( - <> -

- {t('dashboard.settings.passwordPrompt', { - action: showPasswordPrompt === 'copy' - ? t('dashboard.settings.passwordPromptCopy') - : t('dashboard.settings.passwordPromptReveal'), - })} -

- setVerifyPassword(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }} - className="input-base" - placeholder={t('dashboard.settings.currentPassword')} - autoFocus - /> - {verifyError &&

{verifyError}

} -
- - -
- - ) : ( - <> -

{t('dashboard.settings.setPasswordToReveal')}

-
- - -
- - )} -
- )} + setPasswordPromptMode(null)} + onReveal={handleReveal} + onSetPasswordClick={() => { + setPasswordPromptMode(null); + document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' }); + }} + />
-
- +

{t('dashboard.import.nextStep')}

+ +
+ +
)} diff --git a/packages/web/src/pages/ProjectDetail.tsx b/packages/web/src/pages/ProjectDetail.tsx index cd4592e..e28d4ea 100644 --- a/packages/web/src/pages/ProjectDetail.tsx +++ b/packages/web/src/pages/ProjectDetail.tsx @@ -7,6 +7,7 @@ import DocPreview from './tabs/DocPreview'; import ModuleManagement from './tabs/ModuleManagement'; import McpIntegration from './tabs/McpIntegration'; import ProjectSettings from './tabs/ProjectSettings'; +import Usage from './tabs/Usage'; import Badge from '../components/Badge'; import Skeleton from '../components/Skeleton'; @@ -21,6 +22,7 @@ const tabs = [ { key: 'mcp', labelKey: 'dashboard.projectDetail.tabMcp', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1' }, { key: 'docs', labelKey: 'dashboard.projectDetail.tabDocs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, { key: 'modules', labelKey: 'dashboard.projectDetail.tabModules', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' }, + { key: 'usage', labelKey: 'dashboard.projectDetail.tabUsage', icon: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' }, { key: 'settings', labelKey: 'dashboard.projectDetail.tabSettings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }, ] as const; @@ -104,6 +106,7 @@ export default function ProjectDetail() { {activeTab === 'docs' && } {activeTab === 'modules' && } {activeTab === 'mcp' && } + {activeTab === 'usage' && } {activeTab === 'settings' && } diff --git a/packages/web/src/pages/admin/Dashboard.tsx b/packages/web/src/pages/admin/Dashboard.tsx index bc92f10..0911036 100644 --- a/packages/web/src/pages/admin/Dashboard.tsx +++ b/packages/web/src/pages/admin/Dashboard.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; +import BarChart, { type BarChartPoint } from '../../components/BarChart'; type Stats = { totalUsers: number; @@ -137,7 +138,18 @@ export default function Dashboard() { }, ]; - const maxCalls = Math.max(...(trends?.map(t => t.calls) ?? [1]), 1); + const trendPoints: BarChartPoint[] = (trends ?? []).map((point) => ({ + key: point.date, + label: point.date.slice(5), + value: point.calls, + tooltip: ( + <> +
{point.calls} 次调用
+
成功率 {point.successRate}%
+
均耗时 {point.avgDuration}ms
+ + ), + })); return (
@@ -164,40 +176,7 @@ export default function Dashboard() { {/* Trend Chart */}

7 天调用趋势

- {trends && trends.length > 0 ? ( -
- {trends.map((point) => { - const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0; - return ( -
- {/* Tooltip */} -
-
-
{point.calls} 次调用
-
成功率 {point.successRate}%
-
均耗时 {point.avgDuration}ms
-
-
- {/* Bar */} -
-
-
- {point.date.slice(5)} -
- ); - })} -
- ) : ( -
- 暂无调用数据 -
- )} +
{/* Recent Calls */} diff --git a/packages/web/src/pages/tabs/McpIntegration.tsx b/packages/web/src/pages/tabs/McpIntegration.tsx index 50bb62c..4c16cf5 100644 --- a/packages/web/src/pages/tabs/McpIntegration.tsx +++ b/packages/web/src/pages/tabs/McpIntegration.tsx @@ -1,32 +1,51 @@ -import { useState } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../../lib/api'; import { useI18n } from '../../lib/i18n'; import { useLayoutContext } from '../Layout'; +import PasswordRevealPrompt from '../../components/PasswordRevealPrompt'; +import { MCP_CLIENTS, buildClientConfig, sanitizeServerName, type McpClientId } from '../../lib/mcp-clients'; type Project = { id: string; name: string }; +type ApiKeyStatus = { hasKey: boolean; prefix: string | null }; + +type McpInfo = { + projectName: string; + openApiVersion: string; + totalEndpoints: number; + totalModules: number; + toolCount: number; +}; + +type TestStatus = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'ok'; info: McpInfo } + | { kind: 'error'; message: string }; + export default function McpIntegration({ project }: { project: Project }) { - const [copied, setCopied] = useState(null); - const { onOpenSettings } = useLayoutContext(); const { t } = useI18n(); + const { onOpenSettings } = useLayoutContext(); + const mcpUrl = `${window.location.origin}/mcp/${project.id}`; + const serverName = useMemo(() => sanitizeServerName(project.name), [project.name]); + + const [activeClient, setActiveClient] = useState('claude-desktop'); + const [revealedKey, setRevealedKey] = useState(null); + const [passwordPromptOpen, setPasswordPromptOpen] = useState(false); + const [copied, setCopied] = useState(null); + const [testStatus, setTestStatus] = useState({ kind: 'idle' }); const { data: keyStatus } = useQuery({ queryKey: ['api-key-status'], - queryFn: () => apiFetch<{ hasKey: boolean; prefix: string | null }>('/auth/api-key/status'), + queryFn: () => apiFetch('/auth/api-key/status'), }); - const serverName = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); - const configSnippet = JSON.stringify({ - mcpServers: { - [serverName]: { - type: 'http', - url: mcpUrl, - headers: { Authorization: 'Bearer ' }, - }, - }, - }, null, 2); + useEffect(() => { setTestStatus({ kind: 'idle' }); }, [mcpUrl]); + + const client = MCP_CLIENTS.find((c) => c.id === activeClient) ?? MCP_CLIENTS[0]; + const configSnippet = buildClientConfig(client, serverName, mcpUrl, revealedKey); const copyText = (text: string, key: string) => { navigator.clipboard.writeText(text); @@ -34,6 +53,38 @@ export default function McpIntegration({ project }: { project: Project }) { setTimeout(() => setCopied(null), 2000); }; + const requestRevealAndCopy = () => { + if (!keyStatus?.hasKey) return; + if (revealedKey) { + copyText(buildClientConfig(client, serverName, mcpUrl, revealedKey), 'config'); + return; + } + setPasswordPromptOpen(true); + }; + + const handleReveal = (apiKey: string) => { + setRevealedKey(apiKey); + // Copy synchronously off the user gesture chain — re-using `apiKey` instead + // of `revealedKey` because state hasn't propagated yet. + copyText(buildClientConfig(client, serverName, mcpUrl, apiKey), 'config'); + setPasswordPromptOpen(false); + }; + + const testConnection = async () => { + setTestStatus({ kind: 'loading' }); + try { + const res = await fetch(`${mcpUrl}/info`); + if (!res.ok) { + setTestStatus({ kind: 'error', message: `HTTP ${res.status}` }); + return; + } + const info = (await res.json()) as McpInfo; + setTestStatus({ kind: 'ok', info }); + } catch (err) { + setTestStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Network error' }); + } + }; + return (
{/* MCP URL */} @@ -43,47 +94,105 @@ export default function McpIntegration({ project }: { project: Project }) {
{mcpUrl} +
+ {testStatus.kind === 'ok' && ( +
+ +
+
{t('dashboard.mcp.testOk')}
+
+ {testStatus.info.projectName} · OpenAPI {testStatus.info.openApiVersion} · {testStatus.info.totalModules} {t('common.modules')} · {testStatus.info.totalEndpoints} {t('common.endpoints')} · {testStatus.info.toolCount} tools +
+
+
+ )} + {testStatus.kind === 'error' && ( +
+ + {t('dashboard.mcp.testFail')}: {testStatus.message} +
+ )} - {/* Config snippet */} + {/* Client selector + Config */}

{t('dashboard.mcp.configTitle')}

{t('dashboard.mcp.configDesc')}

-
-
{configSnippet}
- + + {/* Client tabs */} +
+ {MCP_CLIENTS.map((c) => ( + + ))}
- {/* API Key guidance */} - {keyStatus && ( -
- {keyStatus.hasKey ? ( -
- -

- {t('dashboard.mcp.keyGenerated')}{' '} - - {' '}{t('dashboard.mcp.keyReplace')} <your-api-key> {t('dashboard.mcp.keyAbove')} -

-
- ) : ( -
- -

{t('dashboard.mcp.noKeyWarning')}

- -
+ {/* Setup hint */} +
+
+ {t('dashboard.mcp.configLocation')}: + {client.configPath} +
+ {client.configPathAlt && ( +
+ {t('dashboard.mcp.configLocationAlt')}: + {client.configPathAlt} +
+ )} +
{client.restartHint}
+
+ + {/* Config snippet */} +
+
{configSnippet}
+
+ {keyStatus?.hasKey && !revealedKey && ( + )} + +
+
+ +
+ setPasswordPromptOpen(false)} + onReveal={handleReveal} + onSetPasswordClick={() => { setPasswordPromptOpen(false); onOpenSettings(); }} + /> +
+ + {/* API Key status */} + {keyStatus && !keyStatus.hasKey && ( +
+ +

{t('dashboard.mcp.noKeyWarning')}

+
)}
diff --git a/packages/web/src/pages/tabs/Usage.tsx b/packages/web/src/pages/tabs/Usage.tsx new file mode 100644 index 0000000..41c3bae --- /dev/null +++ b/packages/web/src/pages/tabs/Usage.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../../lib/api'; +import { useI18n } from '../../lib/i18n'; +import BarChart, { type BarChartPoint } from '../../components/BarChart'; + +type DailyPoint = { date: string; calls: number; errors: number; tokens: number }; +type ToolStat = { toolName: string; calls: number; tokens: number }; +type UsageData = { + days: number; + totals: { calls: number; errors: number; tokens: number }; + daily: DailyPoint[]; + byTool: ToolStat[]; +}; + +export default function Usage({ projectId }: { projectId: string }) { + const { t } = useI18n(); + const [days, setDays] = useState<7 | 30>(7); + + const { data, isLoading } = useQuery({ + queryKey: ['project-usage', projectId, days], + queryFn: () => apiFetch(`/projects/${projectId}/usage?days=${days}`), + }); + + const maxToolCalls = Math.max(...(data?.byTool.map((b) => b.calls) ?? [1]), 1); + + const dailyPoints: BarChartPoint[] = (data?.daily ?? []).map((point) => ({ + key: point.date, + label: point.date.slice(5), + value: point.calls, + secondary: point.errors, + tooltip: ( + <> +
{point.calls} {t('dashboard.usage.calls')}
+ {point.errors > 0 &&
{point.errors} {t('dashboard.usage.errors')}
} +
{point.tokens.toLocaleString()} {t('dashboard.usage.tokens')}
+ + ), + })); + const successRate = data && data.totals.calls > 0 + ? `${Math.round(((data.totals.calls - data.totals.errors) / data.totals.calls) * 100)}%` + : '—'; + + return ( +
+ {/* Range selector */} +
+
+

{t('dashboard.usage.title')}

+

{t('dashboard.usage.desc', { days: String(days) })}

+
+
+ {([7, 30] as const).map((d) => ( + + ))} +
+
+ + {/* Totals */} +
+ + 0 ? 'danger' : 'default'} /> + + +
+ + {/* Daily chart */} +
+

{t('dashboard.usage.dailyTitle')}

+ {isLoading ? ( +
+ ) : ( + + )} +
+ + {/* By tool */} +
+

{t('dashboard.usage.byToolTitle')}

+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) =>
)} +
+ ) : data && data.byTool.length > 0 ? ( +
+ {data.byTool.map((tool) => { + const width = (tool.calls / maxToolCalls) * 100; + return ( +
+ {tool.toolName} +
+
+
+ {tool.calls.toLocaleString()} + {tool.tokens.toLocaleString()}t +
+ ); + })} +
+ ) : ( +

{t('dashboard.usage.empty')}

+ )} +
+
+ ); +} + +function StatCard({ label, value, loading, tone }: { label: string; value: string | number; loading: boolean; tone?: 'default' | 'danger' }) { + return ( +
+
+ {loading ? : value} +
+
{label}
+
+ ); +} diff --git a/prisma/migrations/20260502120000_mcp_call_log_error_message/migration.sql b/prisma/migrations/20260502120000_mcp_call_log_error_message/migration.sql new file mode 100644 index 0000000..52151b1 --- /dev/null +++ b/prisma/migrations/20260502120000_mcp_call_log_error_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "McpCallLog" ADD COLUMN "errorMessage" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1dcb10..4168af6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model McpCallLog { responseSize Int @default(0) clientIp String @default("") estimatedTokens Int? + errorMessage String? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@index([projectId])