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:
@@ -11,13 +11,15 @@
|
||||
"dependencies": {
|
||||
"@agent-fox/shared": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"express": "^5.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
|
||||
33
packages/mcp/src/auth.ts
Normal file
33
packages/mcp/src/auth.ts
Normal 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();
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
54
packages/mcp/src/server.ts
Normal file
54
packages/mcp/src/server.ts
Normal 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;
|
||||
}
|
||||
22
packages/mcp/src/tools/get-endpoint-detail.ts
Normal file
22
packages/mcp/src/tools/get-endpoint-detail.ts
Normal 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) }] };
|
||||
}
|
||||
32
packages/mcp/src/tools/get-project-overview.ts
Normal file
32
packages/mcp/src/tools/get-project-overview.ts
Normal 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) }] };
|
||||
}
|
||||
16
packages/mcp/src/tools/list-endpoints.ts
Normal file
16
packages/mcp/src/tools/list-endpoints.ts
Normal 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) }] };
|
||||
}
|
||||
15
packages/mcp/src/tools/list-modules.ts
Normal file
15
packages/mcp/src/tools/list-modules.ts
Normal 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) }] };
|
||||
}
|
||||
34
packages/mcp/src/tools/search-endpoints.ts
Normal file
34
packages/mcp/src/tools/search-endpoints.ts
Normal 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) }] };
|
||||
}
|
||||
Reference in New Issue
Block a user