feat: add MCP service with 5 multi-level retrieval tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:29:10 +08:00
parent a191a4db00
commit ac60f0bb49
10 changed files with 355 additions and 2 deletions

33
packages/mcp/src/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '@agent-fox/shared';
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' });
return;
}
const apiKey = header.slice(7);
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, apiKeyHash: true },
});
if (!project) {
res.status(404).json({ error: 'Project not found' });
return;
}
const valid = await bcrypt.compare(apiKey, project.apiKeyHash);
if (!valid) {
res.status(401).json({ error: 'Invalid API key' });
return;
}
(req as any).projectId = projectId;
next();
}

View File

@@ -1,5 +1,9 @@
import { randomUUID } from 'node:crypto';
import express from 'express';
import cors from 'cors';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { mcpAuth } from './auth.js';
import { createMcpServer } from './server.js';
const app = express();
app.use(cors());
@@ -9,6 +13,60 @@ app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
// Session storage
const transports: Record<string, StreamableHTTPServerTransport> = {};
// 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);
return;
}
// New session — check for initialize request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
transports[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
const server = createMcpServer(projectId);
await server.connect(transport);
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.' });
}
});
// 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 port = process.env.MCP_PORT || 3001;
app.listen(port, () => {
console.log(`MCP service running on port ${port}`);

View File

@@ -0,0 +1,54 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { getProjectOverview } from './tools/get-project-overview.js';
import { listModules } from './tools/list-modules.js';
import { listEndpoints } from './tools/list-endpoints.js';
import { getEndpointDetail } from './tools/get-endpoint-detail.js';
import { searchEndpoints } from './tools/search-endpoints.js';
export function createMcpServer(projectId: string): McpServer {
const server = new McpServer({
name: 'agent-fox',
version: '0.1.0',
});
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.',
{},
async () => 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.',
{},
async () => 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 }) => listEndpoints(projectId, moduleId),
);
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 }) => 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.',
{
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.'),
},
async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId),
);
return server;
}

View File

@@ -0,0 +1,22 @@
import { prisma } from '@agent-fox/shared';
export async function getEndpointDetail(projectId: string, endpointId: string) {
const endpoint = await prisma.endpoint.findFirst({
where: { id: endpointId, projectId },
include: { module: { select: { 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 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) }] };
}

View File

@@ -0,0 +1,32 @@
import { prisma } from '@agent-fox/shared';
export async function getProjectOverview(projectId: string) {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
name: true, description: true, openApiVersion: true, baseUrl: true,
modules: {
select: { id: true, name: true, _count: { select: { endpoints: true } } },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { endpoints: true } },
},
});
if (!project) {
return { content: [{ type: 'text' as const, text: 'Project not found' }], isError: true };
}
const overview = {
name: project.name,
description: project.description,
version: project.openApiVersion,
baseUrl: project.baseUrl,
totalEndpoints: project._count.endpoints,
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) }] };
}

View File

@@ -0,0 +1,16 @@
import { prisma } from '@agent-fox/shared';
export async function listEndpoints(projectId: string, moduleId: string) {
const mod = await prisma.module.findFirst({ where: { id: moduleId, projectId } });
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 endpoints = await prisma.endpoint.findMany({
where: { projectId, moduleId },
select: { id: true, method: true, path: true, summary: true, deprecated: true },
orderBy: [{ path: 'asc' }, { method: 'asc' }],
});
return { content: [{ type: 'text' as const, text: JSON.stringify(endpoints, null, 2) }] };
}

View File

@@ -0,0 +1,15 @@
import { prisma } from '@agent-fox/shared';
export async function listModules(projectId: string) {
const modules = await prisma.module.findMany({
where: { projectId },
select: { id: true, name: true, description: true, _count: { select: { endpoints: true } } },
orderBy: { sortOrder: 'asc' },
});
const result = modules.map((m) => ({
id: m.id, name: m.name, description: m.description, endpointCount: m._count.endpoints,
}));
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
}

View File

@@ -0,0 +1,34 @@
import { prisma } from '@agent-fox/shared';
export async function searchEndpoints(projectId: string, keyword: string, moduleId?: string) {
const where: any = { projectId };
if (moduleId) where.moduleId = moduleId;
where.OR = [
{ path: { contains: keyword, mode: 'insensitive' } },
{ summary: { contains: keyword, mode: 'insensitive' } },
{ description: { contains: keyword, mode: 'insensitive' } },
{ 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,
});
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 result = endpoints.map((e) => ({
id: e.id, method: e.method, path: e.path, summary: e.summary,
moduleName: e.module.name, deprecated: e.deprecated,
}));
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
}