Compare commits
2 Commits
71c604411d
...
95198e6a07
| Author | SHA1 | Date | |
|---|---|---|---|
| 95198e6a07 | |||
| 8d7692a42d |
@@ -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 } });
|
||||
}
|
||||
14
packages/mcp/src/lib/responses.ts
Normal file
14
packages/mcp/src/lib/responses.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type ToolContent = { type: 'text'; text: string };
|
||||
type ToolResult = { content: ToolContent[]; isError?: true };
|
||||
|
||||
export function jsonOk(value: unknown): ToolResult {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
||||
}
|
||||
|
||||
export function textOk(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
export function textError(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
221
packages/mcp/src/lib/schema-flatten.ts
Normal file
221
packages/mcp/src/lib/schema-flatten.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
type AnySchema = Record<string, any> | null | undefined;
|
||||
|
||||
export type FlatField = {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description?: string;
|
||||
example?: unknown;
|
||||
enum?: unknown[];
|
||||
format?: string;
|
||||
/** Object-typed field — child fields expanded one more level. */
|
||||
fields?: FlatField[];
|
||||
/** Array-typed field whose items are objects — element fields expanded one level. */
|
||||
items?: FlatField[];
|
||||
/** True when the field has nested structure that was NOT expanded (depth limit reached). */
|
||||
nested?: true;
|
||||
};
|
||||
|
||||
export type FlatSchema =
|
||||
| { kind: 'object'; fields: FlatField[]; additionalProperties?: true }
|
||||
| { kind: 'array'; items: FlatSchema }
|
||||
| { kind: 'primitive'; type: string; format?: string; enum?: unknown[]; example?: unknown }
|
||||
| { kind: 'variants'; variants: FlatSchema[] }
|
||||
| { kind: 'unknown' };
|
||||
|
||||
export type FlatParameter = {
|
||||
name: string;
|
||||
in: string;
|
||||
required: boolean;
|
||||
type: string;
|
||||
description?: string;
|
||||
example?: unknown;
|
||||
enum?: unknown[];
|
||||
format?: string;
|
||||
};
|
||||
|
||||
export type FlatRequestBody = {
|
||||
contentType: string;
|
||||
required: boolean;
|
||||
schema: FlatSchema;
|
||||
} | null;
|
||||
|
||||
export type FlatResponse = {
|
||||
status: string;
|
||||
description?: string;
|
||||
contentType?: string;
|
||||
schema?: FlatSchema;
|
||||
};
|
||||
|
||||
const MAX_DEPTH = 1;
|
||||
|
||||
function describeType(s: AnySchema): string {
|
||||
if (!s) return 'unknown';
|
||||
if (Array.isArray(s.type)) return s.type.join('|');
|
||||
if (typeof s.type === 'string') {
|
||||
if (s.type === 'array' && s.items?.type) return `array<${describeType(s.items)}>`;
|
||||
return s.type;
|
||||
}
|
||||
if (s.properties || s.additionalProperties) return 'object';
|
||||
if (s.oneOf || s.anyOf) return 'variants';
|
||||
if (s.allOf) return 'object';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function mergeAllOf(schemas: AnySchema[]): Record<string, any> {
|
||||
const merged: Record<string, any> = { type: 'object', properties: {}, required: [] };
|
||||
for (const s of schemas) {
|
||||
if (!s) continue;
|
||||
if (s.properties) Object.assign(merged.properties, s.properties);
|
||||
if (Array.isArray(s.required)) merged.required.push(...s.required);
|
||||
if (s.description && !merged.description) merged.description = s.description;
|
||||
}
|
||||
merged.required = Array.from(new Set(merged.required));
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveSchema(s: AnySchema): AnySchema {
|
||||
if (!s || typeof s !== 'object') return s;
|
||||
if (Array.isArray(s.allOf) && s.allOf.length > 0) return resolveSchema(mergeAllOf(s.allOf));
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildFields(schema: AnySchema, depth: number): FlatField[] {
|
||||
const resolved = resolveSchema(schema);
|
||||
if (!resolved || typeof resolved !== 'object') return [];
|
||||
const required = new Set<string>(Array.isArray(resolved.required) ? resolved.required : []);
|
||||
const canExpand = depth < MAX_DEPTH;
|
||||
|
||||
return Object.entries(resolved.properties || {}).map(([name, propRaw]) => {
|
||||
const prop = resolveSchema(propRaw as AnySchema);
|
||||
const field: FlatField = {
|
||||
name,
|
||||
type: describeType(prop),
|
||||
required: required.has(name),
|
||||
};
|
||||
if (prop?.description) field.description = String(prop.description);
|
||||
if (prop?.example !== undefined) field.example = prop.example;
|
||||
if (Array.isArray(prop?.enum)) field.enum = prop.enum;
|
||||
if (prop?.format) field.format = String(prop.format);
|
||||
|
||||
const itemsSchema = prop?.items ? resolveSchema(prop.items) : null;
|
||||
const isObj = !!(prop && (prop.properties || prop.type === 'object'));
|
||||
const isArrayOfObjects = !!(itemsSchema && (itemsSchema.properties || itemsSchema.type === 'object'));
|
||||
|
||||
if (isObj) {
|
||||
if (canExpand) field.fields = buildFields(prop, depth + 1);
|
||||
else field.nested = true;
|
||||
} else if (isArrayOfObjects) {
|
||||
if (canExpand) field.items = buildFields(itemsSchema, depth + 1);
|
||||
else field.nested = true;
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenSchema(schema: AnySchema, depth = 0): FlatSchema {
|
||||
if (!schema || typeof schema !== 'object') return { kind: 'unknown' };
|
||||
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
||||
return flattenSchema(mergeAllOf(schema.allOf), depth);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
|
||||
const list = (schema.oneOf || schema.anyOf) as AnySchema[];
|
||||
return { kind: 'variants', variants: list.map((s) => flattenSchema(s, depth)) };
|
||||
}
|
||||
|
||||
if (schema.type === 'array' || schema.items) {
|
||||
const items = depth >= MAX_DEPTH
|
||||
? ({ kind: 'primitive', type: describeType(schema.items) } as FlatSchema)
|
||||
: flattenSchema(schema.items, depth + 1);
|
||||
return { kind: 'array', items };
|
||||
}
|
||||
|
||||
const isObjectLike = schema.type === 'object' || schema.properties || schema.additionalProperties;
|
||||
if (isObjectLike) {
|
||||
const fields = buildFields(schema, depth);
|
||||
const out: Extract<FlatSchema, { kind: 'object' }> = { kind: 'object', fields };
|
||||
if (schema.additionalProperties && Object.keys(schema.properties || {}).length === 0) {
|
||||
out.additionalProperties = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const primitive: Extract<FlatSchema, { kind: 'primitive' }> = {
|
||||
kind: 'primitive',
|
||||
type: describeType(schema),
|
||||
};
|
||||
if (schema.format) primitive.format = String(schema.format);
|
||||
if (Array.isArray(schema.enum)) primitive.enum = schema.enum;
|
||||
if (schema.example !== undefined) primitive.example = schema.example;
|
||||
return primitive;
|
||||
}
|
||||
|
||||
export function flattenParameters(params: unknown): FlatParameter[] {
|
||||
if (!Array.isArray(params)) return [];
|
||||
return params.map((pRaw) => {
|
||||
const p = pRaw as Record<string, any>;
|
||||
const inlineSchema: AnySchema = p.schema ?? p;
|
||||
const param: FlatParameter = {
|
||||
name: String(p.name ?? ''),
|
||||
in: String(p.in ?? 'query'),
|
||||
required: !!p.required,
|
||||
type: describeType(inlineSchema),
|
||||
};
|
||||
if (p.description) param.description = String(p.description);
|
||||
const example = p.example ?? inlineSchema?.example;
|
||||
if (example !== undefined) param.example = example;
|
||||
const enumVals = inlineSchema?.enum ?? p.enum;
|
||||
if (Array.isArray(enumVals)) param.enum = enumVals;
|
||||
const format = inlineSchema?.format ?? p.format;
|
||||
if (format) param.format = String(format);
|
||||
return param;
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenRequestBody(body: unknown): FlatRequestBody {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const b = body as Record<string, any>;
|
||||
// OpenAPI 3 style
|
||||
if (b.content && typeof b.content === 'object') {
|
||||
const contentType = Object.keys(b.content)[0];
|
||||
if (!contentType) return null;
|
||||
return {
|
||||
contentType,
|
||||
required: !!b.required,
|
||||
schema: flattenSchema(b.content[contentType]?.schema),
|
||||
};
|
||||
}
|
||||
// Swagger 2 converted style: { schema }
|
||||
if (b.schema) {
|
||||
return {
|
||||
contentType: 'application/json',
|
||||
required: !!b.required,
|
||||
schema: flattenSchema(b.schema),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function flattenResponses(responses: unknown): FlatResponse[] {
|
||||
if (!responses || typeof responses !== 'object') return [];
|
||||
return Object.entries(responses as Record<string, any>).map(([status, raw]) => {
|
||||
const r = raw as Record<string, any>;
|
||||
const out: FlatResponse = { status };
|
||||
if (r?.description) out.description = String(r.description);
|
||||
// OpenAPI 3
|
||||
if (r?.content && typeof r.content === 'object') {
|
||||
const contentType = Object.keys(r.content)[0];
|
||||
if (contentType) {
|
||||
out.contentType = contentType;
|
||||
out.schema = flattenSchema(r.content[contentType]?.schema);
|
||||
}
|
||||
} else if (r?.schema) {
|
||||
// Swagger 2
|
||||
out.contentType = 'application/json';
|
||||
out.schema = flattenSchema(r.schema);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -18,40 +18,83 @@ export function createMcpServer(projectId: string, clientIp: string = ''): McpSe
|
||||
|
||||
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.',
|
||||
[
|
||||
'Returns a high-level snapshot of this API project: name, version, baseUrl, totalEndpoints, totalModules, and a compact module list (id + name + endpointCount, no descriptions).',
|
||||
'Call this FIRST when you have no prior context about the API. Cheap (~500B).',
|
||||
'Use list_modules instead if you need module descriptions to choose between modules.',
|
||||
'Next steps: pick a moduleId and call list_endpoints, or jump straight to search_endpoints with a keyword.',
|
||||
].join(' '),
|
||||
{},
|
||||
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.',
|
||||
[
|
||||
'Returns ALL modules with their descriptions and endpoint counts. Use this when get_project_overview did not give you enough information to choose a module (i.e., you need the description text to disambiguate).',
|
||||
'Returns { total, modules: [{ id, name, description, endpointCount }] }.',
|
||||
'Do not call this if you already know which module to drill into — go straight to list_endpoints.',
|
||||
].join(' '),
|
||||
{},
|
||||
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 }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
|
||||
[
|
||||
'Returns endpoint summaries inside a single module: { moduleId, moduleName, total, nextCursor, endpoints: [{ id, method, path, summary, deprecated }] }.',
|
||||
'Paginated: pass `cursor` (the previous response\'s `nextCursor`) to fetch the next page; pass `limit` (default 50, max 200) to control page size. `nextCursor` is null when there are no more results.',
|
||||
'Use search_endpoints instead if you have a keyword and don\'t want to paginate through everything.',
|
||||
'Then call get_endpoint_detail with an endpoint id to see parameters/request/response shapes.',
|
||||
].join(' '),
|
||||
{
|
||||
moduleId: z.string().describe('The module id from get_project_overview or list_modules. Example: "clx9k2abc0001..."'),
|
||||
cursor: z.string().optional().describe('Pagination cursor: pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.'),
|
||||
limit: z.number().int().min(1).max(200).optional().describe('Page size, default 50, max 200. Increase only if you actually need more endpoints in one call.'),
|
||||
},
|
||||
async ({ moduleId, cursor, limit }) =>
|
||||
logMcpCall(
|
||||
ctx('list_endpoints', { moduleId, cursor, limit }),
|
||||
() => listEndpoints(projectId, moduleId, { cursor, limit }),
|
||||
),
|
||||
);
|
||||
|
||||
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 }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
|
||||
[
|
||||
'Returns the full callable contract of one endpoint, with OpenAPI schemas already FLATTENED into field lists you can read without parsing JSON Schema.',
|
||||
'Returns: { id, method, path, summary, description, operationId, moduleId, moduleName, deprecated, parameters: [{ name, in, required, type, description?, example?, enum?, format? }], requestBody: { contentType, required, schema } | null, responses: [{ status, description?, contentType?, schema? }] }.',
|
||||
'A `schema` is one of: { kind: "object", fields: [<field>] } | { kind: "array", items: <schema> } | { kind: "primitive", type, ... } | { kind: "variants", variants: [<schema>] }. Each field is { name, type, required, description?, example?, enum?, format?, fields? (nested object expanded one more level), items? (array element fields expanded one level), nested? (true if structure goes deeper than expanded) }. The expansion goes 2 levels deep total — that is enough to construct a request body in 99% of cases without further calls.',
|
||||
'Call this when you are about to actually use an endpoint and need its parameters/body/response shape.',
|
||||
].join(' '),
|
||||
{
|
||||
endpointId: z.string().describe('The endpoint id from list_endpoints or search_endpoints. Example: "clx9k2def0042..."'),
|
||||
},
|
||||
async ({ endpointId }) =>
|
||||
logMcpCall(
|
||||
ctx('get_endpoint_detail', { endpointId }),
|
||||
() => getEndpointDetail(projectId, endpointId),
|
||||
),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'search_endpoints',
|
||||
'Search for endpoints by keyword. Searches across path, summary, description, and operationId. Optionally filter by module. Returns matching endpoint summaries.',
|
||||
[
|
||||
'Case-insensitive substring search across endpoint path, summary, description, and operationId.',
|
||||
'Returns: { total, hasMore, results: [{ id, method, path, summary, deprecated, moduleId, moduleName }], hint? }.',
|
||||
'Prefer this over list_endpoints when you have any keyword from the user request (a noun, a path segment, an action verb). Default page size is 20 (max 100). When `hasMore: true`, narrow the keyword or scope to a moduleId rather than just raising the limit.',
|
||||
'Then call get_endpoint_detail with one of the returned ids.',
|
||||
].join(' '),
|
||||
{
|
||||
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.'),
|
||||
keyword: z.string().min(1).describe('Substring to match. Examples: "user", "/orders", "createPet", "checkout". Single word works best; multi-word phrases match literally.'),
|
||||
moduleId: z.string().optional().describe('Optional: limit search to one module id. Omit to search across the whole project.'),
|
||||
limit: z.number().int().min(1).max(100).optional().describe('Max results to return, default 20, max 100.'),
|
||||
},
|
||||
async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)),
|
||||
async ({ keyword, moduleId, limit }) =>
|
||||
logMcpCall(
|
||||
ctx('search_endpoints', { keyword, moduleId, limit }),
|
||||
() => searchEndpoints(projectId, keyword, { moduleId, limit }),
|
||||
),
|
||||
);
|
||||
|
||||
return server;
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { flattenParameters, flattenRequestBody, flattenResponses } from '../lib/schema-flatten.js';
|
||||
import { jsonOk, textError } from '../lib/responses.js';
|
||||
|
||||
export async function getEndpointDetail(projectId: string, endpointId: string) {
|
||||
const endpoint = await prisma.endpoint.findFirst({
|
||||
where: { id: endpointId, projectId },
|
||||
include: { module: { select: { name: true } } },
|
||||
include: { module: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
if (!endpoint) {
|
||||
return { content: [{ type: 'text' as const, text: `Endpoint "${endpointId}" not found. Use list_endpoints to see available endpoints.` }], isError: true };
|
||||
const sample = await prisma.endpoint.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, method: true, path: true },
|
||||
orderBy: { id: 'asc' },
|
||||
take: 5,
|
||||
});
|
||||
const hint = sample.length > 0
|
||||
? ` Example endpoint IDs in this project: ${sample.map((e) => `${e.id} (${e.method} ${e.path})`).join(', ')}.`
|
||||
: ' This project has no endpoints yet — verify the OpenAPI import succeeded.';
|
||||
return textError(`Endpoint id "${endpointId}" not found in this project. Use search_endpoints with a keyword from the URL or summary, or list_endpoints to browse a module.${hint}`);
|
||||
}
|
||||
|
||||
const detail = {
|
||||
id: endpoint.id, method: endpoint.method, path: endpoint.path,
|
||||
summary: endpoint.summary, description: endpoint.description,
|
||||
operationId: endpoint.operationId, moduleName: endpoint.module.name,
|
||||
parameters: endpoint.parameters, requestBody: endpoint.requestBody,
|
||||
responses: endpoint.responses, deprecated: endpoint.deprecated,
|
||||
};
|
||||
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(detail, null, 2) }] };
|
||||
return jsonOk({
|
||||
id: endpoint.id,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
summary: endpoint.summary,
|
||||
description: endpoint.description,
|
||||
operationId: endpoint.operationId,
|
||||
moduleId: endpoint.module.id,
|
||||
moduleName: endpoint.module.name,
|
||||
deprecated: endpoint.deprecated,
|
||||
parameters: flattenParameters(endpoint.parameters),
|
||||
requestBody: flattenRequestBody(endpoint.requestBody),
|
||||
responses: flattenResponses(endpoint.responses),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { jsonOk, textError } from '../lib/responses.js';
|
||||
|
||||
export async function getProjectOverview(projectId: string) {
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -14,19 +15,18 @@ export async function getProjectOverview(projectId: string) {
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return { content: [{ type: 'text' as const, text: 'Project not found' }], isError: true };
|
||||
return textError('Project not found. The project may have been deleted; ask the user to verify the MCP URL points to an existing project.');
|
||||
}
|
||||
|
||||
const overview = {
|
||||
return jsonOk({
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
version: project.openApiVersion,
|
||||
baseUrl: project.baseUrl,
|
||||
totalEndpoints: project._count.endpoints,
|
||||
totalModules: project.modules.length,
|
||||
modules: project.modules.map((m) => ({
|
||||
id: m.id, name: m.name, endpointCount: m._count.endpoints,
|
||||
})),
|
||||
};
|
||||
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(overview, null, 2) }] };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { jsonOk, textError, textOk } from '../lib/responses.js';
|
||||
|
||||
export async function listEndpoints(projectId: string, moduleId: string) {
|
||||
const mod = await prisma.module.findFirst({ where: { id: moduleId, projectId } });
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export type ListEndpointsOptions = { cursor?: string; limit?: number };
|
||||
|
||||
export async function listEndpoints(
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
opts: ListEndpointsOptions = {},
|
||||
) {
|
||||
const mod = await prisma.module.findFirst({
|
||||
where: { id: moduleId, projectId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!mod) {
|
||||
return { content: [{ type: 'text' as const, text: `Module "${moduleId}" not found. Use get_project_overview or list_modules to see available modules.` }], isError: true };
|
||||
const sample = await prisma.module.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
take: 5,
|
||||
});
|
||||
const hint = sample.length > 0
|
||||
? ` Available modules: ${sample.map((m) => `${m.name} (${m.id})`).join(', ')}.`
|
||||
: ' This project has no modules — verify the OpenAPI import succeeded.';
|
||||
return textError(`Module id "${moduleId}" not found in this project. Use list_modules for the full list.${hint}`);
|
||||
}
|
||||
|
||||
const endpoints = await prisma.endpoint.findMany({
|
||||
where: { projectId, moduleId },
|
||||
select: { id: true, method: true, path: true, summary: true, deprecated: true },
|
||||
orderBy: [{ path: 'asc' }, { method: 'asc' }],
|
||||
});
|
||||
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(endpoints, null, 2) }] };
|
||||
// Pagination orders by id (cuid, monotonic enough for stable cursors); LLM
|
||||
// gets the path/method on every row, so display order doesn't really matter.
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.endpoint.count({ where: { projectId, moduleId } }),
|
||||
prisma.endpoint.findMany({
|
||||
where: { projectId, moduleId },
|
||||
select: { id: true, method: true, path: true, summary: true, deprecated: true },
|
||||
orderBy: { id: 'asc' },
|
||||
take: take + 1,
|
||||
...(opts.cursor ? { cursor: { id: opts.cursor }, skip: 1 } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (total === 0) {
|
||||
return textOk(`Module "${mod.name}" exists but contains 0 endpoints. This usually means the OpenAPI import did not group any endpoints into this module — check the source spec's tags or path prefixes.`);
|
||||
}
|
||||
|
||||
const hasMore = rows.length > take;
|
||||
const endpoints = hasMore ? rows.slice(0, take) : rows;
|
||||
|
||||
return jsonOk({
|
||||
moduleId: mod.id,
|
||||
moduleName: mod.name,
|
||||
total,
|
||||
nextCursor: hasMore ? endpoints[endpoints.length - 1].id : null,
|
||||
endpoints,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { jsonOk, textOk } from '../lib/responses.js';
|
||||
|
||||
export async function listModules(projectId: string) {
|
||||
const modules = await prisma.module.findMany({
|
||||
@@ -7,9 +8,17 @@ export async function listModules(projectId: string) {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
const result = modules.map((m) => ({
|
||||
id: m.id, name: m.name, description: m.description, endpointCount: m._count.endpoints,
|
||||
}));
|
||||
if (modules.length === 0) {
|
||||
return textOk('This project has 0 modules. The OpenAPI import may have failed or the spec contains no operations. Ask the project owner to re-import a valid OpenAPI document.');
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
||||
return jsonOk({
|
||||
total: modules.length,
|
||||
modules: modules.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
endpointCount: m._count.endpoints,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { prisma } from '@agent-fox/shared';
|
||||
import { jsonOk, textOk } from '../lib/responses.js';
|
||||
|
||||
export async function searchEndpoints(projectId: string, keyword: string, moduleId?: string) {
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
export type SearchEndpointsOptions = { moduleId?: string; limit?: number };
|
||||
|
||||
export async function searchEndpoints(
|
||||
projectId: string,
|
||||
keyword: string,
|
||||
opts: SearchEndpointsOptions = {},
|
||||
) {
|
||||
const where: any = { projectId };
|
||||
if (moduleId) where.moduleId = moduleId;
|
||||
if (opts.moduleId) where.moduleId = opts.moduleId;
|
||||
|
||||
where.OR = [
|
||||
{ path: { contains: keyword, mode: 'insensitive' } },
|
||||
@@ -11,24 +21,41 @@ export async function searchEndpoints(projectId: string, keyword: string, module
|
||||
{ operationId: { contains: keyword, mode: 'insensitive' } },
|
||||
];
|
||||
|
||||
const endpoints = await prisma.endpoint.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, method: true, path: true, summary: true, deprecated: true,
|
||||
module: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ path: 'asc' }, { method: 'asc' }],
|
||||
take: 20,
|
||||
});
|
||||
const take = Math.min(Math.max(1, opts.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: `No endpoints found matching "${keyword}". Try a different keyword or use list_modules to browse.` }] };
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.endpoint.count({ where }),
|
||||
prisma.endpoint.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, method: true, path: true, summary: true, deprecated: true,
|
||||
module: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: [{ path: 'asc' }, { method: 'asc' }, { id: 'asc' }],
|
||||
take,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (total === 0) {
|
||||
const moduleHint = opts.moduleId ? ' (within the specified module)' : '';
|
||||
return textOk(`No endpoints match "${keyword}"${moduleHint}. Try a shorter or different keyword (search covers path, summary, description, operationId), or omit moduleId to search across all modules. Use list_modules to browse available modules.`);
|
||||
}
|
||||
|
||||
const result = endpoints.map((e) => ({
|
||||
id: e.id, method: e.method, path: e.path, summary: e.summary,
|
||||
moduleName: e.module.name, deprecated: e.deprecated,
|
||||
const results = rows.map((e) => ({
|
||||
id: e.id,
|
||||
method: e.method,
|
||||
path: e.path,
|
||||
summary: e.summary,
|
||||
deprecated: e.deprecated,
|
||||
moduleId: e.module.id,
|
||||
moduleName: e.module.name,
|
||||
}));
|
||||
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
||||
const hasMore = total > results.length;
|
||||
return jsonOk({
|
||||
total,
|
||||
hasMore,
|
||||
results,
|
||||
...(hasMore && { hint: `Showing first ${results.length} of ${total}. Narrow with a more specific keyword, scope to a moduleId, or raise limit (max ${MAX_LIMIT}).` }),
|
||||
});
|
||||
}
|
||||
|
||||
61
packages/server/src/lib/trends.ts
Normal file
61
packages/server/src/lib/trends.ts
Normal 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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
export { Prisma } from '@prisma/client';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { prisma } from './db.js';
|
||||
export { prisma, Prisma } from './db.js';
|
||||
export type * from './types.js';
|
||||
|
||||
45
packages/web/src/components/BarChart.tsx
Normal file
45
packages/web/src/components/BarChart.tsx
Normal file
@@ -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 <div style={{ height }} className="flex items-center justify-center text-[13px] text-text-muted">{emptyLabel}</div>;
|
||||
}
|
||||
|
||||
const max = Math.max(...points.map((p) => p.value), 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1.5" style={{ height }}>
|
||||
{points.map((point) => {
|
||||
const barHeight = (point.value / max) * 100;
|
||||
const overlayHeight = point.secondary !== undefined && point.value > 0
|
||||
? (point.secondary / point.value) * barHeight
|
||||
: 0;
|
||||
return (
|
||||
<div key={point.key} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
|
||||
{point.tooltip}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 flex items-end relative">
|
||||
<div className="w-full rounded-t-md bg-accent/70 group-hover:bg-accent transition-colors" style={{ height: `${Math.max(barHeight, 2)}%` }} />
|
||||
{overlayHeight > 0 && (
|
||||
<div className="w-full absolute bottom-0 rounded-t-md bg-danger/70" style={{ height: `${overlayHeight}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[9px] text-text-muted">{point.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
packages/web/src/components/PasswordRevealPrompt.tsx
Normal file
76
packages/web/src/components/PasswordRevealPrompt.tsx
Normal file
@@ -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 (
|
||||
<div className="p-3.5 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onSetPasswordClick} className="btn-primary text-[13px] py-1.5">
|
||||
{t('dashboard.settings.setPasswordAction')}
|
||||
</button>
|
||||
<button onClick={onCancel} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3.5 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
<p className="text-[13px] text-text-secondary">{promptText}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && password) submit(); }}
|
||||
className="input-base"
|
||||
placeholder={t('dashboard.settings.currentPassword')}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-[12px] text-danger">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={submit} disabled={loading || !password} className="btn-primary text-[13px] py-1.5">
|
||||
{loading ? t('dashboard.settings.verifying') : t('common.confirm')}
|
||||
</button>
|
||||
<button onClick={onCancel} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (revealedKey) { setRevealedKey(null); }
|
||||
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
|
||||
if (revealedKey) setRevealedKey(null);
|
||||
else setPasswordPromptMode('reveal');
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
>
|
||||
@@ -317,9 +301,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
setKeyCopied(true);
|
||||
setTimeout(() => setKeyCopied(false), 2000);
|
||||
} else {
|
||||
setShowPasswordPrompt('copy');
|
||||
setVerifyPassword('');
|
||||
setVerifyError('');
|
||||
setPasswordPromptMode('copy');
|
||||
}
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
@@ -332,53 +314,20 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPasswordPrompt && (
|
||||
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
{hasPassword ? (
|
||||
<>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
{t('dashboard.settings.passwordPrompt', {
|
||||
action: showPasswordPrompt === 'copy'
|
||||
? t('dashboard.settings.passwordPromptCopy')
|
||||
: t('dashboard.settings.passwordPromptReveal'),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
|
||||
className="input-base"
|
||||
placeholder={t('dashboard.settings.currentPassword')}
|
||||
autoFocus
|
||||
/>
|
||||
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
|
||||
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
|
||||
</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.setPasswordToReveal')}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordPrompt(null);
|
||||
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
className="btn-primary text-[13px] py-1.5"
|
||||
>
|
||||
{t('dashboard.settings.setPasswordAction')}
|
||||
</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PasswordRevealPrompt
|
||||
open={passwordPromptMode !== null}
|
||||
promptText={t('dashboard.settings.passwordPrompt', {
|
||||
action: passwordPromptMode === 'copy'
|
||||
? t('dashboard.settings.passwordPromptCopy')
|
||||
: t('dashboard.settings.passwordPromptReveal'),
|
||||
})}
|
||||
onCancel={() => setPasswordPromptMode(null)}
|
||||
onReveal={handleReveal}
|
||||
onSetPasswordClick={() => {
|
||||
setPasswordPromptMode(null);
|
||||
document.getElementById('set-password-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRotateConfirm(true)}
|
||||
|
||||
@@ -247,6 +247,7 @@ const en = {
|
||||
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||
'dashboard.projectDetail.tabDocs': 'Documentation',
|
||||
'dashboard.projectDetail.tabModules': 'Modules',
|
||||
'dashboard.projectDetail.tabUsage': 'Usage',
|
||||
'dashboard.projectDetail.tabSettings': 'Settings',
|
||||
|
||||
// ===== Import Dialog =====
|
||||
@@ -254,6 +255,8 @@ const en = {
|
||||
'dashboard.import.desc': 'Import a Swagger 2.0 or OpenAPI 3.x document to create a new project.',
|
||||
'dashboard.import.successTitle': 'Import Successful',
|
||||
'dashboard.import.goToProject': 'Go to Project',
|
||||
'dashboard.import.nextStep': 'Next: configure your LLM client to connect to this project via MCP.',
|
||||
'dashboard.import.configureClient': 'Configure MCP Client',
|
||||
|
||||
// ===== Reimport Dialog =====
|
||||
'dashboard.reimport.title': 'Re-import API Document',
|
||||
@@ -271,11 +274,16 @@ const en = {
|
||||
// ===== MCP Integration =====
|
||||
'dashboard.mcp.urlTitle': 'MCP Service URL',
|
||||
'dashboard.mcp.urlDesc': 'Connect your LLM client to this endpoint.',
|
||||
'dashboard.mcp.configTitle': 'Configuration for Claude Code / Cursor',
|
||||
'dashboard.mcp.configDesc': 'Add this to your MCP client configuration.',
|
||||
'dashboard.mcp.keyGenerated': 'API key generated. Copy it from',
|
||||
'dashboard.mcp.keyReplace': 'and replace',
|
||||
'dashboard.mcp.keyAbove': 'above.',
|
||||
'dashboard.mcp.testConnection': 'Test',
|
||||
'dashboard.mcp.testing': 'Testing…',
|
||||
'dashboard.mcp.testOk': 'Reachable',
|
||||
'dashboard.mcp.testFail': 'Failed',
|
||||
'dashboard.mcp.configTitle': 'MCP Client Configuration',
|
||||
'dashboard.mcp.configDesc': 'Pick your client and paste the snippet into the indicated location.',
|
||||
'dashboard.mcp.configLocation': 'Config file',
|
||||
'dashboard.mcp.configLocationAlt': 'Or',
|
||||
'dashboard.mcp.copyWithKey': 'Copy with API key',
|
||||
'dashboard.mcp.revealPrompt': 'Enter your password to embed your real API key in the copied configuration.',
|
||||
'dashboard.mcp.noKeyWarning': 'You need to generate an API key before using MCP.',
|
||||
'dashboard.mcp.openSettings': 'Open Settings',
|
||||
'dashboard.mcp.toolsTitle': 'Available MCP Tools',
|
||||
@@ -286,6 +294,18 @@ const en = {
|
||||
'dashboard.mcp.tool4Desc': 'Get full endpoint details: parameters, request body, responses.',
|
||||
'dashboard.mcp.tool5Desc': 'Search by keyword across all endpoints. Optional moduleId filter.',
|
||||
|
||||
// ===== Usage =====
|
||||
'dashboard.usage.title': 'MCP Usage',
|
||||
'dashboard.usage.desc': 'Last {days} days of MCP tool calls for this project.',
|
||||
'dashboard.usage.totalCalls': 'Total Calls',
|
||||
'dashboard.usage.errors': 'Errors',
|
||||
'dashboard.usage.successRate': 'Success Rate',
|
||||
'dashboard.usage.tokens': 'Est. Tokens',
|
||||
'dashboard.usage.calls': 'calls',
|
||||
'dashboard.usage.dailyTitle': 'Daily Calls',
|
||||
'dashboard.usage.byToolTitle': 'By Tool',
|
||||
'dashboard.usage.empty': 'No calls in this range yet.',
|
||||
|
||||
// ===== Project Settings =====
|
||||
'dashboard.projectSettings.generalTitle': 'General',
|
||||
'dashboard.projectSettings.generalDesc': 'Update your project name and description.',
|
||||
|
||||
@@ -249,6 +249,7 @@ const zh: Record<TranslationKey, string> = {
|
||||
'dashboard.projectDetail.tabMcp': 'MCP',
|
||||
'dashboard.projectDetail.tabDocs': '文档',
|
||||
'dashboard.projectDetail.tabModules': '模块',
|
||||
'dashboard.projectDetail.tabUsage': '调用统计',
|
||||
'dashboard.projectDetail.tabSettings': '设置',
|
||||
|
||||
// ===== Import Dialog =====
|
||||
@@ -256,6 +257,8 @@ const zh: Record<TranslationKey, string> = {
|
||||
'dashboard.import.desc': '导入 Swagger 2.0 或 OpenAPI 3.x 文档以创建新项目。',
|
||||
'dashboard.import.successTitle': '导入成功',
|
||||
'dashboard.import.goToProject': '前往项目',
|
||||
'dashboard.import.nextStep': '下一步:配置你的 LLM 客户端,通过 MCP 连接到此项目。',
|
||||
'dashboard.import.configureClient': '配置 MCP 客户端',
|
||||
|
||||
// ===== Reimport Dialog =====
|
||||
'dashboard.reimport.title': '重新导入 API 文档',
|
||||
@@ -273,11 +276,16 @@ const zh: Record<TranslationKey, string> = {
|
||||
// ===== MCP Integration =====
|
||||
'dashboard.mcp.urlTitle': 'MCP 服务 URL',
|
||||
'dashboard.mcp.urlDesc': '将你的 LLM 客户端连接到此端点。',
|
||||
'dashboard.mcp.configTitle': 'Claude Code / Cursor 配置',
|
||||
'dashboard.mcp.configDesc': '将此内容添加到你的 MCP 客户端配置中。',
|
||||
'dashboard.mcp.keyGenerated': 'API Key 已生成。从',
|
||||
'dashboard.mcp.keyReplace': '复制并替换上方的',
|
||||
'dashboard.mcp.keyAbove': '。',
|
||||
'dashboard.mcp.testConnection': '测试连接',
|
||||
'dashboard.mcp.testing': '测试中…',
|
||||
'dashboard.mcp.testOk': '连接成功',
|
||||
'dashboard.mcp.testFail': '连接失败',
|
||||
'dashboard.mcp.configTitle': 'MCP 客户端配置',
|
||||
'dashboard.mcp.configDesc': '选择你的客户端,将下方代码粘贴到指定位置。',
|
||||
'dashboard.mcp.configLocation': '配置文件',
|
||||
'dashboard.mcp.configLocationAlt': '或',
|
||||
'dashboard.mcp.copyWithKey': '复制(含 API Key)',
|
||||
'dashboard.mcp.revealPrompt': '输入密码以将真实 API Key 嵌入到复制的配置中。',
|
||||
'dashboard.mcp.noKeyWarning': '使用 MCP 前需要先生成 API Key。',
|
||||
'dashboard.mcp.openSettings': '打开设置',
|
||||
'dashboard.mcp.toolsTitle': '可用 MCP 工具',
|
||||
@@ -288,6 +296,18 @@ const zh: Record<TranslationKey, string> = {
|
||||
'dashboard.mcp.tool4Desc': '获取完整端点详情:参数、请求体、响应。',
|
||||
'dashboard.mcp.tool5Desc': '按关键词搜索所有端点。可选 moduleId 过滤。',
|
||||
|
||||
// ===== Usage =====
|
||||
'dashboard.usage.title': 'MCP 调用统计',
|
||||
'dashboard.usage.desc': '过去 {days} 天此项目的 MCP 工具调用情况。',
|
||||
'dashboard.usage.totalCalls': '调用总数',
|
||||
'dashboard.usage.errors': '错误数',
|
||||
'dashboard.usage.successRate': '成功率',
|
||||
'dashboard.usage.tokens': '估算 Token',
|
||||
'dashboard.usage.calls': '次调用',
|
||||
'dashboard.usage.dailyTitle': '每日调用',
|
||||
'dashboard.usage.byToolTitle': '按工具统计',
|
||||
'dashboard.usage.empty': '此时间范围内暂无调用。',
|
||||
|
||||
// ===== Project Settings =====
|
||||
'dashboard.projectSettings.generalTitle': '基本信息',
|
||||
'dashboard.projectSettings.generalDesc': '更新项目名称和描述。',
|
||||
|
||||
96
packages/web/src/lib/mcp-clients.ts
Normal file
96
packages/web/src/lib/mcp-clients.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type McpClientId =
|
||||
| 'claude-desktop'
|
||||
| 'claude-code'
|
||||
| 'cursor'
|
||||
| 'cline'
|
||||
| 'windsurf'
|
||||
| 'codex'
|
||||
| 'github-copilot';
|
||||
|
||||
export type McpClientMeta = {
|
||||
id: McpClientId;
|
||||
label: string;
|
||||
configPath: string;
|
||||
configPathAlt?: string;
|
||||
rootKey: 'mcpServers' | 'servers';
|
||||
restartHint: string;
|
||||
};
|
||||
|
||||
export const MCP_CLIENTS: McpClientMeta[] = [
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
label: 'Claude Desktop',
|
||||
configPath: '~/Library/Application Support/Claude/claude_desktop_config.json',
|
||||
configPathAlt: '%APPDATA%\\Claude\\claude_desktop_config.json',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'Fully quit and reopen Claude Desktop after saving.',
|
||||
},
|
||||
{
|
||||
id: 'claude-code',
|
||||
label: 'Claude Code',
|
||||
configPath: '<project>/.mcp.json',
|
||||
configPathAlt: '~/.claude.json (global)',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'Restart `claude` in the project directory; verify with `claude mcp list`.',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
configPath: '<project>/.cursor/mcp.json',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'Cursor reloads MCP automatically. Use Agent mode (not Ask) to invoke tools.',
|
||||
},
|
||||
{
|
||||
id: 'cline',
|
||||
label: 'Cline (VS Code)',
|
||||
configPath: 'Cline sidebar → MCP Servers → settings (gear icon)',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'Cline reconnects automatically after saving.',
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
configPath: '~/.codeium/windsurf/mcp_config.json',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'Restart Windsurf after saving the config file.',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex (OpenAI)',
|
||||
configPath: '~/.codex/config.json',
|
||||
configPathAlt: '<project>/.codex/mcp.json',
|
||||
rootKey: 'mcpServers',
|
||||
restartHint: 'New Codex sessions pick up the config automatically.',
|
||||
},
|
||||
{
|
||||
id: 'github-copilot',
|
||||
label: 'GitHub Copilot (VS Code)',
|
||||
configPath: '<project>/.vscode/mcp.json',
|
||||
rootKey: 'servers',
|
||||
restartHint: 'Verify with command palette → "MCP: List Servers". Note the root key is `servers`, not `mcpServers`.',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_PLACEHOLDER = '<your-api-key>';
|
||||
|
||||
export function buildClientConfig(
|
||||
client: McpClientMeta,
|
||||
serverName: string,
|
||||
url: string,
|
||||
apiKey: string | null,
|
||||
): string {
|
||||
const config = {
|
||||
[client.rootKey]: {
|
||||
[serverName]: {
|
||||
type: 'http',
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${apiKey ?? KEY_PLACEHOLDER}` },
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
export function sanitizeServerName(projectName: string): string {
|
||||
return projectName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'agent-fox';
|
||||
}
|
||||
@@ -143,8 +143,14 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">{t('dashboard.import.goToProject')}</button>
|
||||
<p className="text-[13px] text-text-secondary">{t('dashboard.import.nextStep')}</p>
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-outline">{t('dashboard.import.goToProject')}</button>
|
||||
<button onClick={() => navigate(`/dashboard/projects/${result.project.id}`)} className="btn-primary">
|
||||
{t('dashboard.import.configureClient')}
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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' && <DocPreview projectId={project.id} />}
|
||||
{activeTab === 'modules' && <ModuleManagement projectId={project.id} />}
|
||||
{activeTab === 'mcp' && <McpIntegration project={project} />}
|
||||
{activeTab === 'usage' && <Usage projectId={project.id} />}
|
||||
{activeTab === 'settings' && <ProjectSettings project={{ ...project, _count: project._count }} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: (
|
||||
<>
|
||||
<div className="font-medium text-text-primary">{point.calls} 次调用</div>
|
||||
<div className="text-text-muted">成功率 {point.successRate}%</div>
|
||||
<div className="text-text-muted">均耗时 {point.avgDuration}ms</div>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
@@ -164,40 +176,7 @@ export default function Dashboard() {
|
||||
{/* Trend Chart */}
|
||||
<div className="xl:col-span-3 card p-5">
|
||||
<h3 className="section-title mb-4">7 天调用趋势</h3>
|
||||
{trends && trends.length > 0 ? (
|
||||
<div className="flex items-end gap-1.5 h-[180px]">
|
||||
{trends.map((point) => {
|
||||
const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0;
|
||||
return (
|
||||
<div key={point.date} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
|
||||
<div className="font-medium text-text-primary">{point.calls} 次调用</div>
|
||||
<div className="text-text-muted">成功率 {point.successRate}%</div>
|
||||
<div className="text-text-muted">均耗时 {point.avgDuration}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bar */}
|
||||
<div className="w-full flex-1 flex items-end">
|
||||
<div
|
||||
className="w-full rounded-t-md bg-accent/70 hover:bg-accent transition-colors cursor-default"
|
||||
style={{
|
||||
height: `${Math.max(height, 2)}%`,
|
||||
transition: 'height 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] text-text-muted">{point.date.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
|
||||
暂无调用数据
|
||||
</div>
|
||||
)}
|
||||
<BarChart points={trendPoints} emptyLabel="暂无调用数据" />
|
||||
</div>
|
||||
|
||||
{/* Recent Calls */}
|
||||
|
||||
@@ -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<string | null>(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<McpClientId>('claude-desktop');
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [testStatus, setTestStatus] = useState<TestStatus>({ kind: 'idle' });
|
||||
|
||||
const { data: keyStatus } = useQuery({
|
||||
queryKey: ['api-key-status'],
|
||||
queryFn: () => apiFetch<{ hasKey: boolean; prefix: string | null }>('/auth/api-key/status'),
|
||||
queryFn: () => apiFetch<ApiKeyStatus>('/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 <your-api-key>' },
|
||||
},
|
||||
},
|
||||
}, 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 (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
{/* MCP URL */}
|
||||
@@ -43,47 +94,105 @@ export default function McpIntegration({ project }: { project: Project }) {
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted text-[13px] font-mono text-text-primary truncate">{mcpUrl}</code>
|
||||
<button onClick={() => copyText(mcpUrl, 'url')} className="btn-outline shrink-0">
|
||||
{copied === 'url' ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('common.copy')}</>
|
||||
)}
|
||||
{copied === 'url' ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
<button onClick={testConnection} disabled={testStatus.kind === 'loading'} className="btn-outline shrink-0">
|
||||
{testStatus.kind === 'loading' ? t('dashboard.mcp.testing') : t('dashboard.mcp.testConnection')}
|
||||
</button>
|
||||
</div>
|
||||
{testStatus.kind === 'ok' && (
|
||||
<div className="mt-2.5 p-3 rounded-lg bg-success-muted text-[12px] text-text-secondary flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-success shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
<div>
|
||||
<div className="font-medium text-success">{t('dashboard.mcp.testOk')}</div>
|
||||
<div className="text-text-muted mt-0.5">
|
||||
{testStatus.info.projectName} · OpenAPI {testStatus.info.openApiVersion} · {testStatus.info.totalModules} {t('common.modules')} · {testStatus.info.totalEndpoints} {t('common.endpoints')} · {testStatus.info.toolCount} tools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testStatus.kind === 'error' && (
|
||||
<div className="mt-2.5 p-3 rounded-lg bg-danger-muted text-[12px] text-danger flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
<span>{t('dashboard.mcp.testFail')}: {testStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Config snippet */}
|
||||
{/* Client selector + Config */}
|
||||
<section>
|
||||
<p className="section-title">{t('dashboard.mcp.configTitle')}</p>
|
||||
<p className="section-desc mb-3">{t('dashboard.mcp.configDesc')}</p>
|
||||
<div className="relative">
|
||||
<pre className="code-block text-xs">{configSnippet}</pre>
|
||||
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn absolute top-2.5 right-2.5">
|
||||
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
|
||||
</button>
|
||||
|
||||
{/* Client tabs */}
|
||||
<div className="flex flex-wrap gap-1 p-1 rounded-lg bg-bg-tertiary border border-border-muted mb-3">
|
||||
{MCP_CLIENTS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveClient(c.id)}
|
||||
className={`px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
|
||||
activeClient === c.id
|
||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* API Key guidance */}
|
||||
{keyStatus && (
|
||||
<div className="mt-3">
|
||||
{keyStatus.hasKey ? (
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M5 13l4 4L19 7" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
{t('dashboard.mcp.keyGenerated')}{' '}
|
||||
<button onClick={onOpenSettings} className="text-accent hover:underline font-medium">{t('common.settings')}</button>
|
||||
{' '}{t('dashboard.mcp.keyReplace')} <code className="text-xs font-mono bg-bg-inset px-1 py-0.5 rounded"><your-api-key></code> {t('dashboard.mcp.keyAbove')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
|
||||
{t('dashboard.mcp.openSettings')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Setup hint */}
|
||||
<div className="mb-3 p-3 rounded-lg bg-bg-tertiary border border-border-muted text-[12px] text-text-secondary space-y-1">
|
||||
<div>
|
||||
<span className="text-text-muted">{t('dashboard.mcp.configLocation')}:</span>
|
||||
<code className="font-mono text-text-primary">{client.configPath}</code>
|
||||
</div>
|
||||
{client.configPathAlt && (
|
||||
<div>
|
||||
<span className="text-text-muted">{t('dashboard.mcp.configLocationAlt')}:</span>
|
||||
<code className="font-mono text-text-primary">{client.configPathAlt}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-text-muted pt-1">{client.restartHint}</div>
|
||||
</div>
|
||||
|
||||
{/* Config snippet */}
|
||||
<div className="relative">
|
||||
<pre className="code-block text-xs">{configSnippet}</pre>
|
||||
<div className="absolute top-2.5 right-2.5 flex gap-1.5">
|
||||
{keyStatus?.hasKey && !revealedKey && (
|
||||
<button
|
||||
onClick={requestRevealAndCopy}
|
||||
className="copy-btn"
|
||||
title={t('dashboard.mcp.copyWithKey')}
|
||||
>
|
||||
{t('dashboard.mcp.copyWithKey')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => copyText(configSnippet, 'config')} className="copy-btn">
|
||||
{copied === 'config' ? `${t('common.copied')}!` : t('common.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<PasswordRevealPrompt
|
||||
open={passwordPromptOpen}
|
||||
promptText={t('dashboard.mcp.revealPrompt')}
|
||||
onCancel={() => setPasswordPromptOpen(false)}
|
||||
onReveal={handleReveal}
|
||||
onSetPasswordClick={() => { setPasswordPromptOpen(false); onOpenSettings(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key status */}
|
||||
{keyStatus && !keyStatus.hasKey && (
|
||||
<div className="mt-3 flex items-center gap-3 p-3.5 rounded-lg bg-warning-muted border border-warning/20">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary flex-1">{t('dashboard.mcp.noKeyWarning')}</p>
|
||||
<button onClick={onOpenSettings} className="btn-primary shrink-0 text-[13px] py-1.5 px-3">
|
||||
{t('dashboard.mcp.openSettings')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
125
packages/web/src/pages/tabs/Usage.tsx
Normal file
125
packages/web/src/pages/tabs/Usage.tsx
Normal file
@@ -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<UsageData>(`/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: (
|
||||
<>
|
||||
<div className="font-medium text-text-primary">{point.calls} {t('dashboard.usage.calls')}</div>
|
||||
{point.errors > 0 && <div className="text-danger">{point.errors} {t('dashboard.usage.errors')}</div>}
|
||||
<div className="text-text-muted">{point.tokens.toLocaleString()} {t('dashboard.usage.tokens')}</div>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
const successRate = data && data.totals.calls > 0
|
||||
? `${Math.round(((data.totals.calls - data.totals.errors) / data.totals.calls) * 100)}%`
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Range selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="section-title">{t('dashboard.usage.title')}</p>
|
||||
<p className="section-desc">{t('dashboard.usage.desc', { days: String(days) })}</p>
|
||||
</div>
|
||||
<div className="flex gap-0.5 p-0.5 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
{([7, 30] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-3 py-1.5 rounded-md text-[12px] font-medium transition-all ${
|
||||
days === d ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label={t('dashboard.usage.totalCalls')} value={data?.totals.calls ?? 0} loading={isLoading} />
|
||||
<StatCard label={t('dashboard.usage.errors')} value={data?.totals.errors ?? 0} loading={isLoading} tone={data && data.totals.errors > 0 ? 'danger' : 'default'} />
|
||||
<StatCard label={t('dashboard.usage.successRate')} value={successRate} loading={isLoading} />
|
||||
<StatCard label={t('dashboard.usage.tokens')} value={(data?.totals.tokens ?? 0).toLocaleString()} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<div className="card p-5">
|
||||
<h3 className="section-title mb-4">{t('dashboard.usage.dailyTitle')}</h3>
|
||||
{isLoading ? (
|
||||
<div className="h-[180px] skeleton" />
|
||||
) : (
|
||||
<BarChart points={dailyPoints} emptyLabel={t('dashboard.usage.empty')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By tool */}
|
||||
<div className="card p-5">
|
||||
<h3 className="section-title mb-4">{t('dashboard.usage.byToolTitle')}</h3>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => <div key={i} className="skeleton h-6" />)}
|
||||
</div>
|
||||
) : data && data.byTool.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{data.byTool.map((tool) => {
|
||||
const width = (tool.calls / maxToolCalls) * 100;
|
||||
return (
|
||||
<div key={tool.toolName} className="flex items-center gap-3 text-[12px]">
|
||||
<code className="font-mono text-accent w-44 shrink-0 truncate">{tool.toolName}</code>
|
||||
<div className="flex-1 h-5 bg-bg-tertiary rounded-md overflow-hidden">
|
||||
<div className="h-full bg-accent/70 rounded-md transition-all" style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
<span className="text-text-muted tabular-nums w-16 text-right">{tool.calls.toLocaleString()}</span>
|
||||
<span className="text-text-muted tabular-nums w-20 text-right">{tool.tokens.toLocaleString()}t</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-text-muted">{t('dashboard.usage.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, loading, tone }: { label: string; value: string | number; loading: boolean; tone?: 'default' | 'danger' }) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className={`text-2xl font-bold tabular-nums ${tone === 'danger' ? 'text-danger' : 'text-text-primary'}`}>
|
||||
{loading ? <span className="skeleton inline-block w-16 h-7 align-middle" /> : value}
|
||||
</div>
|
||||
<div className="text-[12px] text-text-muted mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "McpCallLog" ADD COLUMN "errorMessage" TEXT;
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user