diff --git a/package-lock.json b/package-lock.json index 5d1d2ed..5532628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "adm-zip": "^0.5.16", "axios": "^1.11.0", "bcryptjs": "^3.0.2", + "cors": "^2.8.5", "dotenv": "^16.6.1", "dotenv-expand": "^12.0.2", "express": "^4.21.2", @@ -46,6 +47,7 @@ "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/vite": "^4.1.12", "@types/bcryptjs": "^3.0.0", + "@types/cors": "^2.8.19", "@types/express": "^4.17.23", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", @@ -4291,6 +4293,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/src/controllers/openApiController.ts b/src/controllers/openApiController.ts index 61b3449..0309c2d 100644 --- a/src/controllers/openApiController.ts +++ b/src/controllers/openApiController.ts @@ -5,12 +5,83 @@ import { getToolStats, OpenAPIGenerationOptions, } from '../services/openApiGeneratorService.js'; +import { getServerByName } from '../services/mcpService.js'; /** * Controller for OpenAPI generation endpoints * Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration */ +/** + * Convert query parameters to their proper types based on the tool's input schema + */ +function convertQueryParametersToTypes( + queryParams: Record, + inputSchema: Record, +): Record { + if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) { + return queryParams; + } + + const convertedParams: Record = {}; + const properties = inputSchema.properties; + + for (const [key, value] of Object.entries(queryParams)) { + const propDef = properties[key]; + if (!propDef || typeof propDef !== 'object') { + // No schema definition found, keep as is + convertedParams[key] = value; + continue; + } + + const propType = propDef.type; + + try { + switch (propType) { + case 'integer': + case 'number': + // Convert string to number + if (typeof value === 'string') { + const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value); + convertedParams[key] = isNaN(numValue) ? value : numValue; + } else { + convertedParams[key] = value; + } + break; + + case 'boolean': + // Convert string to boolean + if (typeof value === 'string') { + convertedParams[key] = value.toLowerCase() === 'true' || value === '1'; + } else { + convertedParams[key] = value; + } + break; + + case 'array': + // Handle array conversion if needed (e.g., comma-separated strings) + if (typeof value === 'string' && value.includes(',')) { + convertedParams[key] = value.split(',').map((item) => item.trim()); + } else { + convertedParams[key] = value; + } + break; + + default: + // For string and other types, keep as is + convertedParams[key] = value; + break; + } + } catch (error) { + // If conversion fails, keep the original value + console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error); + convertedParams[key] = value; + } + } + + return convertedParams; +} + /** * Generate and return OpenAPI specification * GET /api/openapi.json @@ -99,8 +170,24 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis // Import handleCallToolRequest function const { handleCallToolRequest } = await import('../services/mcpService.js'); + // Get the server info to access the tool's input schema + const serverInfo = getServerByName(serverName); + let inputSchema: Record = {}; + + if (serverInfo) { + // Find the tool in the server's tools list + const fullToolName = `${serverName}-${toolName}`; + const tool = serverInfo.tools.find( + (t: any) => t.name === fullToolName || t.name === toolName, + ); + if (tool && tool.inputSchema) { + inputSchema = tool.inputSchema as Record; + } + } + // Prepare arguments from query params (GET) or body (POST) - const args = req.method === 'GET' ? req.query : req.body || {}; + let args = req.method === 'GET' ? req.query : req.body || {}; + args = convertQueryParametersToTypes(args, inputSchema); // Create a mock request structure that matches what handleCallToolRequest expects const mockRequest = { diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index b9f501f..a67eb89 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -614,7 +614,7 @@ export const getServersInfo = (): Omit[] => }; // Get server by name -const getServerByName = (name: string): ServerInfo | undefined => { +export const getServerByName = (name: string): ServerInfo | undefined => { return serverInfos.find((serverInfo) => serverInfo.name === name); }; diff --git a/tests/controllers/openApiController.test.ts b/tests/controllers/openApiController.test.ts new file mode 100644 index 0000000..d5e5cb8 --- /dev/null +++ b/tests/controllers/openApiController.test.ts @@ -0,0 +1,224 @@ +// Simple unit test to validate the type conversion logic +describe('Parameter Type Conversion Logic', () => { + // Extract the conversion function for testing + function convertQueryParametersToTypes( + queryParams: Record, + inputSchema: Record + ): Record { + if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) { + return queryParams; + } + + const convertedParams: Record = {}; + const properties = inputSchema.properties; + + for (const [key, value] of Object.entries(queryParams)) { + const propDef = properties[key]; + if (!propDef || typeof propDef !== 'object') { + // No schema definition found, keep as is + convertedParams[key] = value; + continue; + } + + const propType = propDef.type; + + try { + switch (propType) { + case 'integer': + case 'number': + // Convert string to number + if (typeof value === 'string') { + const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value); + convertedParams[key] = isNaN(numValue) ? value : numValue; + } else { + convertedParams[key] = value; + } + break; + + case 'boolean': + // Convert string to boolean + if (typeof value === 'string') { + convertedParams[key] = value.toLowerCase() === 'true' || value === '1'; + } else { + convertedParams[key] = value; + } + break; + + case 'array': + // Handle array conversion if needed (e.g., comma-separated strings) + if (typeof value === 'string' && value.includes(',')) { + convertedParams[key] = value.split(',').map(item => item.trim()); + } else { + convertedParams[key] = value; + } + break; + + default: + // For string and other types, keep as is + convertedParams[key] = value; + break; + } + } catch (error) { + // If conversion fails, keep the original value + console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error); + convertedParams[key] = value; + } + } + + return convertedParams; + } + + test('should convert integer parameters correctly', () => { + const queryParams = { + limit: '5', + offset: '10', + name: 'test' + }; + + const inputSchema = { + type: 'object', + properties: { + limit: { type: 'integer' }, + offset: { type: 'integer' }, + name: { type: 'string' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + limit: 5, // Converted to integer + offset: 10, // Converted to integer + name: 'test' // Remains string + }); + }); + + test('should convert number parameters correctly', () => { + const queryParams = { + price: '19.99', + discount: '0.15' + }; + + const inputSchema = { + type: 'object', + properties: { + price: { type: 'number' }, + discount: { type: 'number' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + price: 19.99, + discount: 0.15 + }); + }); + + test('should convert boolean parameters correctly', () => { + const queryParams = { + enabled: 'true', + disabled: 'false', + active: '1', + inactive: '0' + }; + + const inputSchema = { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + disabled: { type: 'boolean' }, + active: { type: 'boolean' }, + inactive: { type: 'boolean' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + enabled: true, + disabled: false, + active: true, + inactive: false + }); + }); + + test('should convert array parameters correctly', () => { + const queryParams = { + tags: 'tag1,tag2,tag3', + ids: '1,2,3' + }; + + const inputSchema = { + type: 'object', + properties: { + tags: { type: 'array' }, + ids: { type: 'array' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + tags: ['tag1', 'tag2', 'tag3'], + ids: ['1', '2', '3'] + }); + }); + + test('should handle missing schema gracefully', () => { + const queryParams = { + limit: '5', + name: 'test' + }; + + const result = convertQueryParametersToTypes(queryParams, {}); + + expect(result).toEqual({ + limit: '5', // Should remain as string + name: 'test' // Should remain as string + }); + }); + + test('should handle properties not in schema', () => { + const queryParams = { + limit: '5', + unknownParam: 'value' + }; + + const inputSchema = { + type: 'object', + properties: { + limit: { type: 'integer' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + limit: 5, // Converted based on schema + unknownParam: 'value' // Kept as is (no schema) + }); + }); + + test('should handle invalid number conversion gracefully', () => { + const queryParams = { + limit: 'not-a-number', + price: 'invalid' + }; + + const inputSchema = { + type: 'object', + properties: { + limit: { type: 'integer' }, + price: { type: 'number' } + } + }; + + const result = convertQueryParametersToTypes(queryParams, inputSchema); + + expect(result).toEqual({ + limit: 'not-a-number', // Should remain as string when conversion fails + price: 'invalid' // Should remain as string when conversion fails + }); + }); +}); \ No newline at end of file