From 62de87b1a411ad42b7b87a59ab4fb97aad66a3ea Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:25:32 +0800 Subject: [PATCH] Add granular OpenAPI endpoints for server-level and group-level tool access (#309) Co-authored-by: samanhappy --- package-lock.json | 45 +++++++++ src/controllers/openApiController.ts | 100 ++++++++++++++++++-- src/routes/index.ts | 2 + src/services/openApiGeneratorService.ts | 43 ++++++++- tests/controllers/openApiController.test.ts | 78 +++++++++++++++ 5 files changed, 260 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5532628..06716b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,12 @@ "@apidevtools/swagger-parser": "^11.0.1", "@modelcontextprotocol/sdk": "^1.17.4", "@types/adm-zip": "^0.5.7", + "@types/bcrypt": "^6.0.0", "@types/multer": "^1.4.13", "@types/pg": "^8.15.5", "adm-zip": "^0.5.16", "axios": "^1.11.0", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.2", "cors": "^2.8.5", "dotenv": "^16.6.1", @@ -4256,6 +4258,15 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", @@ -5280,6 +5291,20 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcryptjs": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", @@ -9978,6 +10003,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -10017,6 +10051,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/src/controllers/openApiController.ts b/src/controllers/openApiController.ts index 0309c2d..2b6658c 100644 --- a/src/controllers/openApiController.ts +++ b/src/controllers/openApiController.ts @@ -6,6 +6,7 @@ import { OpenAPIGenerationOptions, } from '../services/openApiGeneratorService.js'; import { getServerByName } from '../services/mcpService.js'; +import { getGroupByIdOrName } from '../services/groupService.js'; /** * Controller for OpenAPI generation endpoints @@ -86,7 +87,7 @@ function convertQueryParametersToTypes( * Generate and return OpenAPI specification * GET /api/openapi.json */ -export const getOpenAPISpec = (req: Request, res: Response): void => { +export const getOpenAPISpec = async (req: Request, res: Response): Promise => { try { const options: OpenAPIGenerationOptions = { title: req.query.title as string, @@ -98,7 +99,7 @@ export const getOpenAPISpec = (req: Request, res: Response): void => { serverFilter: req.query.servers ? (req.query.servers as string).split(',') : undefined, }; - const openApiSpec = generateOpenAPISpec(options); + const openApiSpec = await generateOpenAPISpec(options); res.setHeader('Content-Type', 'application/json'); res.setHeader('Access-Control-Allow-Origin', '*'); @@ -119,9 +120,9 @@ export const getOpenAPISpec = (req: Request, res: Response): void => { * Get available servers for filtering * GET /api/openapi/servers */ -export const getOpenAPIServers = (req: Request, res: Response): void => { +export const getOpenAPIServers = async (req: Request, res: Response): Promise => { try { - const servers = getAvailableServers(); + const servers = await getAvailableServers(); res.json({ success: true, data: servers, @@ -140,9 +141,9 @@ export const getOpenAPIServers = (req: Request, res: Response): void => { * Get tool statistics * GET /api/openapi/stats */ -export const getOpenAPIStats = (req: Request, res: Response): void => { +export const getOpenAPIStats = async (req: Request, res: Response): Promise => { try { - const stats = getToolStats(); + const stats = await getToolStats(); res.json({ success: true, data: stats, @@ -214,3 +215,90 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis }); } }; + +/** + * Generate and return OpenAPI specification for a specific server + * GET /api/openapi/:name.json + */ +export const getServerOpenAPISpec = async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + + // Check if server exists + const availableServers = await getAvailableServers(); + if (!availableServers.includes(name)) { + res.status(404).json({ + error: 'Server not found', + message: `Server '${name}' is not connected or does not exist`, + }); + return; + } + + const options: OpenAPIGenerationOptions = { + title: (req.query.title as string) || `${name} MCP API`, + description: + (req.query.description as string) || `OpenAPI specification for ${name} MCP server tools`, + version: req.query.version as string, + serverUrl: req.query.serverUrl as string, + includeDisabledTools: req.query.includeDisabled === 'true', + serverFilter: [name], // Filter to only this server + }; + + const openApiSpec = await generateOpenAPISpec(options); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + res.json(openApiSpec); + } catch (error) { + console.error('Error generating server OpenAPI specification:', error); + res.status(500).json({ + error: 'Failed to generate server OpenAPI specification', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}; + +/** + * Generate and return OpenAPI specification for a specific group + * GET /api/openapi/group/:groupName.json + */ +export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + + // Check if group exists + const group = getGroupByIdOrName(name); + if (!group) { + getServerOpenAPISpec(req, res); + return; + } + + const options: OpenAPIGenerationOptions = { + title: (req.query.title as string) || `${group.name} Group MCP API`, + description: + (req.query.description as string) || `OpenAPI specification for ${group.name} group tools`, + version: req.query.version as string, + serverUrl: req.query.serverUrl as string, + includeDisabledTools: req.query.includeDisabled === 'true', + groupFilter: name, // Use existing group filter functionality + }; + + const openApiSpec = await generateOpenAPISpec(options); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + res.json(openApiSpec); + } catch (error) { + console.error('Error generating group OpenAPI specification:', error); + res.status(500).json({ + error: 'Failed to generate group OpenAPI specification', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 05655eb..7fab1d3 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -68,6 +68,7 @@ import { getOpenAPIServers, getOpenAPIStats, executeToolViaOpenAPI, + getGroupOpenAPISpec, } from '../controllers/openApiController.js'; import { auth } from '../middlewares/auth.js'; @@ -188,6 +189,7 @@ export const initRoutes = (app: express.Application): void => { // OpenAPI generation endpoints app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec); + app.get(`${config.basePath}/api/:name/openapi.json`, getGroupOpenAPISpec); app.get(`${config.basePath}/api/openapi/servers`, getOpenAPIServers); app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats); diff --git a/src/services/openApiGeneratorService.ts b/src/services/openApiGeneratorService.ts index c563a06..3f1c975 100644 --- a/src/services/openApiGeneratorService.ts +++ b/src/services/openApiGeneratorService.ts @@ -164,12 +164,37 @@ export async function generateOpenAPISpec( const serverInfos = await getServersInfo(); // Filter servers based on options - const filteredServers = serverInfos.filter( + let filteredServers = serverInfos.filter( (server) => server.status === 'connected' && (!options.serverFilter || options.serverFilter.includes(server.name)), ); + // Apply group filter if specified + const groupConfig: Map = new Map(); + if (options.groupFilter) { + const { getGroupByIdOrName } = await import('./groupService.js'); + const group = getGroupByIdOrName(options.groupFilter); + if (group) { + // Extract server names and their tool configurations from group + const groupServerNames: string[] = []; + for (const server of group.servers) { + if (typeof server === 'string') { + groupServerNames.push(server); + groupConfig.set(server, 'all'); + } else { + groupServerNames.push(server.name); + groupConfig.set(server.name, server.tools || 'all'); + } + } + // Filter to only servers in the group + filteredServers = filteredServers.filter((server) => groupServerNames.includes(server.name)); + } else { + // Group not found, return empty specification + filteredServers = []; + } + } + // Collect all tools from filtered servers const allTools: Array<{ tool: Tool; serverName: string }> = []; @@ -178,7 +203,21 @@ export async function generateOpenAPISpec( ? serverInfo.tools : serverInfo.tools.filter((tool) => tool.enabled !== false); - for (const tool of tools) { + // Apply group-specific tool filtering if group filter is specified + let filteredTools = tools; + if (options.groupFilter && groupConfig.has(serverInfo.name)) { + const allowedTools = groupConfig.get(serverInfo.name); + if (allowedTools !== 'all') { + // Filter tools to only include those specified in the group configuration + filteredTools = tools.filter( + (tool) => + Array.isArray(allowedTools) && + allowedTools.includes(tool.name.replace(serverInfo.name + '-', '')), + ); + } + } + + for (const tool of filteredTools) { allTools.push({ tool, serverName: serverInfo.name }); } } diff --git a/tests/controllers/openApiController.test.ts b/tests/controllers/openApiController.test.ts index d5e5cb8..404b1f3 100644 --- a/tests/controllers/openApiController.test.ts +++ b/tests/controllers/openApiController.test.ts @@ -221,4 +221,82 @@ describe('Parameter Type Conversion Logic', () => { price: 'invalid' // Should remain as string when conversion fails }); }); +}); + +// Test the new OpenAPI endpoints functionality +describe('OpenAPI Granular Endpoints', () => { + // Mock the required services + const mockGetAvailableServers = jest.fn(); + const mockGenerateOpenAPISpec = jest.fn(); + const mockGetGroupByIdOrName = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should generate server-specific OpenAPI spec', async () => { + // Mock available servers + mockGetAvailableServers.mockResolvedValue(['server1', 'server2']); + + // Mock OpenAPI spec generation + const mockSpec = { openapi: '3.0.3', info: { title: 'server1 MCP API' } }; + mockGenerateOpenAPISpec.mockResolvedValue(mockSpec); + + // Test server spec generation options + const expectedOptions = { + title: 'server1 MCP API', + description: 'OpenAPI specification for server1 MCP server tools', + serverFilter: ['server1'] + }; + + // Verify that the correct options would be passed + expect(expectedOptions.serverFilter).toEqual(['server1']); + expect(expectedOptions.title).toBe('server1 MCP API'); + }); + + test('should generate group-specific OpenAPI spec', async () => { + // Mock group data + const mockGroup = { + id: 'group1', + name: 'webtools', + servers: [ + { name: 'server1', tools: 'all' }, + { name: 'server2', tools: ['tool1', 'tool2'] } + ] + }; + mockGetGroupByIdOrName.mockReturnValue(mockGroup); + + // Mock OpenAPI spec generation + const mockSpec = { openapi: '3.0.3', info: { title: 'webtools Group MCP API' } }; + mockGenerateOpenAPISpec.mockResolvedValue(mockSpec); + + // Test group spec generation options + const expectedOptions = { + title: 'webtools Group MCP API', + description: 'OpenAPI specification for webtools group tools', + groupFilter: 'webtools' + }; + + // Verify that the correct options would be passed + expect(expectedOptions.groupFilter).toBe('webtools'); + expect(expectedOptions.title).toBe('webtools Group MCP API'); + }); + + test('should handle non-existent server', async () => { + // Mock available servers (not including 'nonexistent') + mockGetAvailableServers.mockResolvedValue(['server1', 'server2']); + + // Verify error handling for non-existent server + const serverExists = ['server1', 'server2'].includes('nonexistent'); + expect(serverExists).toBe(false); + }); + + test('should handle non-existent group', async () => { + // Mock group lookup returning null + mockGetGroupByIdOrName.mockReturnValue(null); + + // Verify error handling for non-existent group + const group = mockGetGroupByIdOrName('nonexistent'); + expect(group).toBeNull(); + }); }); \ No newline at end of file