mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -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",
|
"@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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user