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

@@ -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<void> {
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 <api-key>.');
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;
}

View File

@@ -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<string, StreamableHTTPServerTransport> = {};
// 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<string, SessionEntry>();
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}`);

View File

@@ -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<string, unknown>): Record<string, unknown> {
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<any>): Promise<any> {
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);

View File

@@ -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 } });
}