import _ from "lodash"; interface McpTool { name: string; description: string; inputSchema: any; "x-props": { method: string; path: string; operationId?: string; tag?: string; deprecated?: boolean; summary?: string; }; } /** * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. */ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] { const tools: McpTool[] = []; if (!openApiJson || typeof openApiJson !== "object") { console.warn("Invalid OpenAPI JSON"); return tools; } const paths = openApiJson.paths || {}; if (Object.keys(paths).length === 0) { console.warn("No paths found in OpenAPI spec"); return tools; } for (const [path, methods] of Object.entries(paths)) { if (!path || typeof path !== "string") continue; if (path.startsWith("/mcp")) continue; if (!methods || typeof methods !== "object") continue; for (const [method, operation] of Object.entries(methods)) { const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"]; if (!validMethods.includes(method.toLowerCase())) continue; if (!operation || typeof operation !== "object") continue; const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; if (!tags.length || !tags.some(t => typeof t === "string" && t.toLowerCase().includes(filterTag) )) continue; try { const tool = createToolFromOperation(path, method, operation, tags); if (tool) { tools.push(tool); } } catch (error) { console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error); continue; } } } return tools; } /** * Buat MCP tool dari operation OpenAPI */ function createToolFromOperation( path: string, method: string, operation: any, tags: string[] ): McpTool | null { try { const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; const name = cleanToolName(rawName); if (!name || name === "unnamed_tool") { console.warn(`Invalid tool name for ${method} ${path}`); return null; } const description = operation.description || operation.summary || `Execute ${method.toUpperCase()} ${path}`; // ✅ Extract schema berdasarkan method let schema; if (method.toLowerCase() === "get") { // ✅ Untuk GET, ambil dari parameters (query/path) schema = extractParametersSchema(operation.parameters || []); } else { // ✅ Untuk POST/PUT/etc, ambil dari requestBody schema = extractRequestBodySchema(operation); } const inputSchema = createInputSchema(schema); return { name, description, "x-props": { method: method.toUpperCase(), path, operationId: operation.operationId, tag: tags[0], deprecated: operation.deprecated || false, summary: operation.summary, }, inputSchema, }; } catch (error) { console.error(`Failed to create tool from operation:`, error); return null; } } /** * Extract schema dari parameters (untuk GET requests) */ function extractParametersSchema(parameters: any[]): any { if (!Array.isArray(parameters) || parameters.length === 0) { return null; } const properties: any = {}; const required: string[] = []; for (const param of parameters) { if (!param || typeof param !== "object") continue; // ✅ Support path, query, dan header parameters if (["path", "query", "header"].includes(param.in)) { const paramName = param.name; if (!paramName || typeof paramName !== "string") continue; properties[paramName] = { type: param.schema?.type || "string", description: param.description || `${param.in} parameter: ${paramName}`, }; // ✅ Copy field tambahan dari schema if (param.schema) { const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"]; for (const field of allowedFields) { if (param.schema[field] !== undefined) { properties[paramName][field] = param.schema[field]; } } } if (param.required === true) { required.push(paramName); } } } if (Object.keys(properties).length === 0) { return null; } return { type: "object", properties, required, }; } /** * Extract schema dari requestBody (untuk POST/PUT/etc requests) */ function extractRequestBodySchema(operation: any): any { if (!operation.requestBody?.content) { return null; } const content = operation.requestBody.content; const contentTypes = [ "application/json", "multipart/form-data", "application/x-www-form-urlencoded", "text/plain", ]; for (const contentType of contentTypes) { if (content[contentType]?.schema) { return content[contentType].schema; } } for (const [_, value] of Object.entries(content)) { if (value?.schema) { return value.schema; } } return null; } /** * Buat input schema yang valid untuk MCP */ function createInputSchema(schema: any): any { const defaultSchema = { type: "object", properties: {}, additionalProperties: false, }; if (!schema || typeof schema !== "object") { return defaultSchema; } try { const properties: any = {}; const required: string[] = []; const originalRequired = Array.isArray(schema.required) ? schema.required : []; if (schema.properties && typeof schema.properties === "object") { for (const [key, prop] of Object.entries(schema.properties)) { if (!key || typeof key !== "string") continue; try { const cleanProp = cleanProperty(prop); if (cleanProp) { properties[key] = cleanProp; // ✅ PERBAIKAN: Check optional flag dengan benar const isOptional = prop?.optional === true || prop?.optional === "true"; const isInRequired = originalRequired.includes(key); // ✅ Hanya masukkan ke required jika memang required DAN bukan optional if (isInRequired && !isOptional) { required.push(key); } } } catch (error) { console.error(`Error cleaning property ${key}:`, error); continue; } } } return { type: "object", properties, required, additionalProperties: false, }; } catch (error) { console.error("Error creating input schema:", error); return defaultSchema; } } /** * Bersihkan property dari field custom */ function cleanProperty(prop: any): any | null { if (!prop || typeof prop !== "object") { return { type: "string" }; } try { const cleaned: any = { type: prop.type || "string", }; const allowedFields = [ "description", "examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format", "multipleOf", "exclusiveMinimum", "exclusiveMaximum", ]; for (const field of allowedFields) { if (prop[field] !== undefined && prop[field] !== null) { cleaned[field] = prop[field]; } } if (prop.properties && typeof prop.properties === "object") { cleaned.properties = {}; for (const [key, value] of Object.entries(prop.properties)) { const cleanedNested = cleanProperty(value); if (cleanedNested) { cleaned.properties[key] = cleanedNested; } } if (Array.isArray(prop.required)) { cleaned.required = prop.required.filter((r: any) => typeof r === "string"); } } if (prop.items) { cleaned.items = cleanProperty(prop.items); } if (Array.isArray(prop.oneOf)) { cleaned.oneOf = prop.oneOf.map(cleanProperty).filter(Boolean); } if (Array.isArray(prop.anyOf)) { cleaned.anyOf = prop.anyOf.map(cleanProperty).filter(Boolean); } if (Array.isArray(prop.allOf)) { cleaned.allOf = prop.allOf.map(cleanProperty).filter(Boolean); } return cleaned; } catch (error) { console.error("Error cleaning property:", error); return null; } } /** * Bersihkan nama tool */ function cleanToolName(name: string): string { if (!name || typeof name !== "string") { return "unnamed_tool"; } try { return name .replace(/[{}]/g, "") .replace(/[^a-zA-Z0-9_]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") .replace(/^(get|post|put|delete|patch|api)_/i, "") .replace(/^(get_|post_|put_|delete_|patch_|api_)+/gi, "") .replace(/(^_|_$)/g, "") || "unnamed_tool"; } catch (error) { console.error("Error cleaning tool name:", error); return "unnamed_tool"; } } /** * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP */ export async function getMcpTools(url: string, filterTag: string): Promise { try { console.log(`Fetching OpenAPI spec from: ${url}`); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const openApiJson = await response.json(); const tools = convertOpenApiToMcpTools(openApiJson, filterTag); console.log(`✅ Successfully generated ${tools.length} MCP tools`); return tools; } catch (error) { console.error("Error fetching MCP tools:", error); throw error; } }