Add OpenAPI specification generation for OpenWebUI integration (#295)

Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
Copilot
2025-08-26 14:54:19 +08:00
committed by GitHub
parent f6ee9beed3
commit 976e90679d
11 changed files with 1185 additions and 122 deletions

View 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,
};
}