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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
packages/mcp/src/lib/errors.ts
Normal file
12
packages/mcp/src/lib/errors.ts
Normal 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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user