mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-23 18:29:21 -05:00
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:
45
package-lock.json
generated
45
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<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
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user