mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Add OpenAPI specification generation for OpenWebUI integration (#295)
Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
134
src/controllers/openApiController.ts
Normal file
134
src/controllers/openApiController.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
generateOpenAPISpec,
|
||||
getAvailableServers,
|
||||
getToolStats,
|
||||
OpenAPIGenerationOptions
|
||||
} from '../services/openApiGeneratorService.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
*/
|
||||
export const getOpenAPISpec = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const options: OpenAPIGenerationOptions = {
|
||||
title: req.query.title as string,
|
||||
description: req.query.description as string,
|
||||
version: req.query.version as string,
|
||||
serverUrl: req.query.serverUrl as string,
|
||||
includeDisabledTools: req.query.includeDisabled === 'true',
|
||||
groupFilter: req.query.group as string,
|
||||
serverFilter: req.query.servers ? (req.query.servers as string).split(',') : undefined
|
||||
};
|
||||
|
||||
const openApiSpec = 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 OpenAPI specification:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate OpenAPI specification',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available servers for filtering
|
||||
* GET /api/openapi/servers
|
||||
*/
|
||||
export const getOpenAPIServers = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const servers = getAvailableServers();
|
||||
res.json({
|
||||
success: true,
|
||||
data: servers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting available servers:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get available servers',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tool statistics
|
||||
* GET /api/openapi/stats
|
||||
*/
|
||||
export const getOpenAPIStats = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const stats = getToolStats();
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting tool statistics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get tool statistics',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute tool via OpenAPI-compatible endpoint
|
||||
* This allows OpenWebUI to call MCP tools directly
|
||||
* POST /api/tools/:serverName/:toolName
|
||||
* GET /api/tools/:serverName/:toolName (for simple tools)
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
const args = req.method === 'GET'
|
||||
? req.query
|
||||
: req.body || {};
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: toolName, // Just use the tool name without server prefix as it gets added by handleCallToolRequest
|
||||
arguments: args,
|
||||
},
|
||||
};
|
||||
|
||||
const extra = {
|
||||
sessionId: req.headers['x-session-id'] as string || 'openapi-session',
|
||||
server: serverName,
|
||||
};
|
||||
|
||||
console.log(`OpenAPI tool execution: ${serverName}/${toolName} with args:`, args);
|
||||
|
||||
const result = await handleCallToolRequest(mockRequest, extra);
|
||||
|
||||
// Return the result in OpenAPI format (matching MCP tool response structure)
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error executing tool via OpenAPI:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to execute tool',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -63,6 +63,12 @@ import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
import { healthCheck } from '../controllers/healthController.js';
|
||||
import {
|
||||
getOpenAPISpec,
|
||||
getOpenAPIServers,
|
||||
getOpenAPIStats,
|
||||
executeToolViaOpenAPI,
|
||||
} from '../controllers/openApiController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -180,6 +186,15 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Public configuration endpoint (no auth required to check skipAuth setting)
|
||||
app.get(`${config.basePath}/public-config`, getPublicConfig);
|
||||
|
||||
// OpenAPI generation endpoints (no auth required for OpenWebUI integration)
|
||||
app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec);
|
||||
app.get(`${config.basePath}/api/openapi/servers`, getOpenAPIServers);
|
||||
app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats);
|
||||
|
||||
// OpenAPI-compatible tool execution endpoints (no auth required for OpenWebUI integration)
|
||||
app.get(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -26,6 +27,7 @@ export class AppServer {
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app.use(cors());
|
||||
this.port = config.port;
|
||||
this.basePath = config.basePath;
|
||||
}
|
||||
|
||||
316
src/services/openApiGeneratorService.ts
Normal file
316
src/services/openApiGeneratorService.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { Tool } from '../types/index.js';
|
||||
import { getServersInfo } from './mcpService.js';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Service for generating OpenAPI 3.x specifications from MCP tools
|
||||
* This enables integration with OpenWebUI and other OpenAPI-compatible systems
|
||||
*/
|
||||
|
||||
export interface OpenAPIGenerationOptions {
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
serverUrl?: string;
|
||||
includeDisabledTools?: boolean;
|
||||
groupFilter?: string;
|
||||
serverFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MCP tool input schema to OpenAPI parameter or request body schema
|
||||
*/
|
||||
function convertToolSchemaToOpenAPI(tool: Tool): {
|
||||
parameters?: OpenAPIV3.ParameterObject[];
|
||||
requestBody?: OpenAPIV3.RequestBodyObject;
|
||||
} {
|
||||
const schema = tool.inputSchema as any;
|
||||
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If schema has properties, convert them to parameters or request body
|
||||
if (schema.properties && typeof schema.properties === 'object') {
|
||||
const properties = schema.properties;
|
||||
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||
|
||||
// For simple tools with only primitive parameters, use query parameters
|
||||
const hasComplexTypes = Object.values(properties).some(
|
||||
(prop: any) =>
|
||||
prop.type === 'object' ||
|
||||
prop.type === 'array' ||
|
||||
(prop.type === 'string' && prop.enum && prop.enum.length > 10),
|
||||
);
|
||||
|
||||
if (!hasComplexTypes && Object.keys(properties).length <= 10) {
|
||||
// Use query parameters for simple tools
|
||||
const parameters: OpenAPIV3.ParameterObject[] = Object.entries(properties).map(
|
||||
([name, prop]: [string, any]) => ({
|
||||
name,
|
||||
in: 'query',
|
||||
required: required.includes(name),
|
||||
description: prop.description || `Parameter ${name}`,
|
||||
schema: {
|
||||
type: prop.type || 'string',
|
||||
...(prop.enum && { enum: prop.enum }),
|
||||
...(prop.default !== undefined && { default: prop.default }),
|
||||
...(prop.format && { format: prop.format }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { parameters };
|
||||
} else {
|
||||
// Use request body for complex tools
|
||||
const requestBody: OpenAPIV3.RequestBodyObject = {
|
||||
required: required.length > 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length > 0 && { required }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { requestBody };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OpenAPI operation from MCP tool
|
||||
*/
|
||||
function generateOperationFromTool(tool: Tool, serverName: string): OpenAPIV3.OperationObject {
|
||||
const { parameters, requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
const operation: OpenAPIV3.OperationObject = {
|
||||
summary: tool.description || `Execute ${tool.name} tool`,
|
||||
description: tool.description || `Execute the ${tool.name} tool from ${serverName} server`,
|
||||
operationId: `${serverName}_${tool.name}`,
|
||||
tags: [serverName],
|
||||
...(parameters && parameters.length > 0 && { parameters }),
|
||||
...(requestBody && { requestBody }),
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful tool execution',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
description: 'Bad request - invalid parameters',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: 'Internal server error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OpenAPI specification from MCP tools
|
||||
*/
|
||||
export function generateOpenAPISpec(options: OpenAPIGenerationOptions = {}): OpenAPIV3.Document {
|
||||
const serverInfos = getServersInfo();
|
||||
|
||||
// Filter servers based on options
|
||||
const filteredServers = serverInfos.filter(
|
||||
(server) =>
|
||||
server.status === 'connected' &&
|
||||
(!options.serverFilter || options.serverFilter.includes(server.name)),
|
||||
);
|
||||
|
||||
// Collect all tools from filtered servers
|
||||
const allTools: Array<{ tool: Tool; serverName: string }> = [];
|
||||
|
||||
for (const serverInfo of filteredServers) {
|
||||
const tools = options.includeDisabledTools
|
||||
? serverInfo.tools
|
||||
: serverInfo.tools.filter((tool) => tool.enabled !== false);
|
||||
|
||||
for (const tool of tools) {
|
||||
allTools.push({ tool, serverName: serverInfo.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate paths from tools
|
||||
const paths: OpenAPIV3.PathsObject = {};
|
||||
|
||||
for (const { tool, serverName } of allTools) {
|
||||
const operation = generateOperationFromTool(tool, serverName);
|
||||
const { requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
|
||||
// Create path for the tool
|
||||
const pathName = `/tools/${serverName}/${tool.name}`;
|
||||
const method = requestBody ? 'post' : 'get';
|
||||
|
||||
if (!paths[pathName]) {
|
||||
paths[pathName] = {};
|
||||
}
|
||||
|
||||
paths[pathName][method] = operation;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
// Get server URL
|
||||
const baseUrl =
|
||||
options.serverUrl ||
|
||||
settings.systemConfig?.install?.baseUrl ||
|
||||
`http://localhost:${config.port}`;
|
||||
const serverUrl = `${baseUrl}${config.basePath}/api`;
|
||||
|
||||
// Generate OpenAPI document
|
||||
const openApiDoc: OpenAPIV3.Document = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: options.title || 'MCPHub API',
|
||||
description:
|
||||
options.description ||
|
||||
'OpenAPI specification for MCP tools managed by MCPHub. This enables integration with OpenWebUI and other OpenAPI-compatible systems.',
|
||||
version: options.version || '1.0.0',
|
||||
contact: {
|
||||
name: 'MCPHub',
|
||||
url: 'https://github.com/samanhappy/mcphub',
|
||||
},
|
||||
license: {
|
||||
name: 'ISC',
|
||||
url: 'https://github.com/samanhappy/mcphub/blob/main/LICENSE',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: serverUrl,
|
||||
description: 'MCPHub API Server',
|
||||
},
|
||||
],
|
||||
paths,
|
||||
components: {
|
||||
schemas: {
|
||||
ToolResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
],
|
||||
tags: filteredServers.map((server) => ({
|
||||
name: server.name,
|
||||
description: `Tools from ${server.name} server`,
|
||||
})),
|
||||
};
|
||||
|
||||
return openApiDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available server names for filtering
|
||||
*/
|
||||
export function getAvailableServers(): string[] {
|
||||
const serverInfos = getServersInfo();
|
||||
return serverInfos.filter((server) => server.status === 'connected').map((server) => server.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about available tools
|
||||
*/
|
||||
export function getToolStats(): {
|
||||
totalServers: number;
|
||||
totalTools: number;
|
||||
serverBreakdown: Array<{ name: string; toolCount: number; status: string }>;
|
||||
} {
|
||||
const serverInfos = getServersInfo();
|
||||
|
||||
const serverBreakdown = serverInfos.map((server) => ({
|
||||
name: server.name,
|
||||
toolCount: server.tools.length,
|
||||
status: server.status,
|
||||
}));
|
||||
|
||||
const totalTools = serverInfos
|
||||
.filter((server) => server.status === 'connected')
|
||||
.reduce((sum, server) => sum + server.tools.length, 0);
|
||||
|
||||
return {
|
||||
totalServers: serverInfos.filter((server) => server.status === 'connected').length,
|
||||
totalTools,
|
||||
serverBreakdown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user