From ac60f0bb49cb4b731983bdb482d5d1f1410aa573 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 2 Apr 2026 13:29:10 +0800 Subject: [PATCH] feat: add MCP service with 5 multi-level retrieval tools Co-Authored-By: Claude Sonnet 4.6 --- packages/mcp/package.json | 6 +- packages/mcp/src/auth.ts | 33 +++++++ packages/mcp/src/index.ts | 58 +++++++++++++ packages/mcp/src/server.ts | 54 ++++++++++++ packages/mcp/src/tools/get-endpoint-detail.ts | 22 +++++ .../mcp/src/tools/get-project-overview.ts | 32 +++++++ packages/mcp/src/tools/list-endpoints.ts | 16 ++++ packages/mcp/src/tools/list-modules.ts | 15 ++++ packages/mcp/src/tools/search-endpoints.ts | 34 ++++++++ pnpm-lock.yaml | 87 +++++++++++++++++++ 10 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/src/auth.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/tools/get-endpoint-detail.ts create mode 100644 packages/mcp/src/tools/get-project-overview.ts create mode 100644 packages/mcp/src/tools/list-endpoints.ts create mode 100644 packages/mcp/src/tools/list-modules.ts create mode 100644 packages/mcp/src/tools/search-endpoints.ts diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2b2be38..d3cb970 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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" } diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts new file mode 100644 index 0000000..77f65a9 --- /dev/null +++ b/packages/mcp/src/auth.ts @@ -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 { + 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(); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 08790b4..6ba356b 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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 = {}; + +// 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}`); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000..b927835 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -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; +} diff --git a/packages/mcp/src/tools/get-endpoint-detail.ts b/packages/mcp/src/tools/get-endpoint-detail.ts new file mode 100644 index 0000000..bd06370 --- /dev/null +++ b/packages/mcp/src/tools/get-endpoint-detail.ts @@ -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) }] }; +} diff --git a/packages/mcp/src/tools/get-project-overview.ts b/packages/mcp/src/tools/get-project-overview.ts new file mode 100644 index 0000000..d266949 --- /dev/null +++ b/packages/mcp/src/tools/get-project-overview.ts @@ -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) }] }; +} diff --git a/packages/mcp/src/tools/list-endpoints.ts b/packages/mcp/src/tools/list-endpoints.ts new file mode 100644 index 0000000..1c6b053 --- /dev/null +++ b/packages/mcp/src/tools/list-endpoints.ts @@ -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) }] }; +} diff --git a/packages/mcp/src/tools/list-modules.ts b/packages/mcp/src/tools/list-modules.ts new file mode 100644 index 0000000..f200d7e --- /dev/null +++ b/packages/mcp/src/tools/list-modules.ts @@ -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) }] }; +} diff --git a/packages/mcp/src/tools/search-endpoints.ts b/packages/mcp/src/tools/search-endpoints.ts new file mode 100644 index 0000000..1b9b453 --- /dev/null +++ b/packages/mcp/src/tools/search-endpoints.ts @@ -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) }] }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0286153..bcc31a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.12.0 version: 1.29.0(zod@3.25.76) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 cors: specifier: ^2.8.5 version: 2.8.6 @@ -34,6 +37,9 @@ importers: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/cors': specifier: ^2.8.17 version: 2.8.19 @@ -52,6 +58,9 @@ importers: '@agent-fox/shared': specifier: workspace:* version: link:../shared + '@apidevtools/swagger-parser': + specifier: ^12.1.0 + version: 12.1.0(openapi-types@12.1.3) bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -64,6 +73,9 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 zod: specifier: ^3.24.0 version: 3.25.76 @@ -111,6 +123,22 @@ importers: packages: + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -456,6 +484,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} @@ -481,6 +512,14 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -492,6 +531,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + bcrypt@6.0.0: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} @@ -523,6 +565,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -763,6 +808,10 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -940,6 +989,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1182,6 +1234,25 @@ packages: snapshots: + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -1437,6 +1508,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 @@ -1466,6 +1539,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -1477,6 +1554,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + argparse@2.0.1: {} + bcrypt@6.0.0: dependencies: node-addon-api: 8.7.0 @@ -1525,6 +1604,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + call-me-maybe@1.0.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -1781,6 +1862,10 @@ snapshots: jose@6.2.2: {} + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -1916,6 +2001,8 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-types@12.1.3: {} + parseurl@1.3.3: {} path-key@3.1.1: {}