Add granular OpenAPI endpoints for server-level and group-level tool access (#309)

Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
Copilot
2025-08-27 17:25:32 +08:00
committed by GitHub
parent bbd6c891c9
commit 62de87b1a4
5 changed files with 260 additions and 8 deletions

45
package-lock.json generated
View File

@@ -12,10 +12,12 @@
"@apidevtools/swagger-parser": "^11.0.1", "@apidevtools/swagger-parser": "^11.0.1",
"@modelcontextprotocol/sdk": "^1.17.4", "@modelcontextprotocol/sdk": "^1.17.4",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^1.4.13", "@types/multer": "^1.4.13",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.11.0", "axios": "^1.11.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
@@ -4256,6 +4258,15 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/bcryptjs": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
@@ -5280,6 +5291,20 @@
], ],
"license": "MIT" "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": { "node_modules/bcryptjs": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
@@ -9978,6 +10003,15 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -10017,6 +10051,17 @@
"url": "https://opencollective.com/node-fetch" "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",

View File

@@ -6,6 +6,7 @@ import {
OpenAPIGenerationOptions, OpenAPIGenerationOptions,
} from '../services/openApiGeneratorService.js'; } from '../services/openApiGeneratorService.js';
import { getServerByName } from '../services/mcpService.js'; import { getServerByName } from '../services/mcpService.js';
import { getGroupByIdOrName } from '../services/groupService.js';
/** /**
* Controller for OpenAPI generation endpoints * Controller for OpenAPI generation endpoints
@@ -86,7 +87,7 @@ function convertQueryParametersToTypes(
* Generate and return OpenAPI specification * Generate and return OpenAPI specification
* GET /api/openapi.json * GET /api/openapi.json
*/ */
export const getOpenAPISpec = (req: Request, res: Response): void => { export const getOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
try { try {
const options: OpenAPIGenerationOptions = { const options: OpenAPIGenerationOptions = {
title: req.query.title as string, 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, 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('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
@@ -119,9 +120,9 @@ export const getOpenAPISpec = (req: Request, res: Response): void => {
* Get available servers for filtering * Get available servers for filtering
* GET /api/openapi/servers * GET /api/openapi/servers
*/ */
export const getOpenAPIServers = (req: Request, res: Response): void => { export const getOpenAPIServers = async (req: Request, res: Response): Promise<void> => {
try { try {
const servers = getAvailableServers(); const servers = await getAvailableServers();
res.json({ res.json({
success: true, success: true,
data: servers, data: servers,
@@ -140,9 +141,9 @@ export const getOpenAPIServers = (req: Request, res: Response): void => {
* Get tool statistics * Get tool statistics
* GET /api/openapi/stats * GET /api/openapi/stats
*/ */
export const getOpenAPIStats = (req: Request, res: Response): void => { export const getOpenAPIStats = async (req: Request, res: Response): Promise<void> => {
try { try {
const stats = getToolStats(); const stats = await getToolStats();
res.json({ res.json({
success: true, success: true,
data: stats, 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<void> => {
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<void> => {
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',
});
}
};

View File

@@ -68,6 +68,7 @@ import {
getOpenAPIServers, getOpenAPIServers,
getOpenAPIStats, getOpenAPIStats,
executeToolViaOpenAPI, executeToolViaOpenAPI,
getGroupOpenAPISpec,
} from '../controllers/openApiController.js'; } from '../controllers/openApiController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
@@ -188,6 +189,7 @@ export const initRoutes = (app: express.Application): void => {
// OpenAPI generation endpoints // OpenAPI generation endpoints
app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec); 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/servers`, getOpenAPIServers);
app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats); app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats);

View File

@@ -164,12 +164,37 @@ export async function generateOpenAPISpec(
const serverInfos = await getServersInfo(); const serverInfos = await getServersInfo();
// Filter servers based on options // Filter servers based on options
const filteredServers = serverInfos.filter( let filteredServers = serverInfos.filter(
(server) => (server) =>
server.status === 'connected' && server.status === 'connected' &&
(!options.serverFilter || options.serverFilter.includes(server.name)), (!options.serverFilter || options.serverFilter.includes(server.name)),
); );
// Apply group filter if specified
const groupConfig: Map<string, string[] | 'all'> = 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 // Collect all tools from filtered servers
const allTools: Array<{ tool: Tool; serverName: string }> = []; const allTools: Array<{ tool: Tool; serverName: string }> = [];
@@ -178,7 +203,21 @@ export async function generateOpenAPISpec(
? serverInfo.tools ? serverInfo.tools
: serverInfo.tools.filter((tool) => tool.enabled !== false); : 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 }); allTools.push({ tool, serverName: serverInfo.name });
} }
} }

View File

@@ -221,4 +221,82 @@ describe('Parameter Type Conversion Logic', () => {
price: 'invalid' // Should remain as string when conversion fails 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();
});
}); });