diff --git a/mcp_tool_convert.ts.v3.txt b/mcp_tool_convert.ts.v3.txt new file mode 100644 index 0000000..640d1c9 --- /dev/null +++ b/mcp_tool_convert.ts.v3.txt @@ -0,0 +1,380 @@ +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 (!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, "") + // ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate) + .toLowerCase() + || "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; + } +} + diff --git a/src/lib/mcp_tool_convert.ts b/src/lib/mcp_tool_convert.ts index 640d1c9..6ad3691 100644 --- a/src/lib/mcp_tool_convert.ts +++ b/src/lib/mcp_tool_convert.ts @@ -16,8 +16,11 @@ interface McpTool { /** * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. + * * @param openApiJson OpenAPI JSON specification object. + * @param filterTag A string or array of strings. Operations must match at least one tag + * (case-insensitive partial match). */ -export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] { +export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string | string[]): McpTool[] { const tools: McpTool[] = []; if (!openApiJson || typeof openApiJson !== "object") { @@ -25,6 +28,15 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M return tools; } + // Cast filterTag to an array and normalize to lowercase for comparison + const filterTags = _.castArray(filterTag) + .filter(t => typeof t === "string" && t.trim() !== "") + .map(t => t.toLowerCase()); + + if (filterTags.length === 0) { + console.warn("Filter tag is empty or invalid. Returning all tools with tags."); + } + const paths = openApiJson.paths || {}; if (Object.keys(paths).length === 0) { @@ -44,10 +56,19 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M if (!operation || typeof operation !== "object") continue; const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; + const lowerCaseTags = tags.map(t => typeof t === "string" ? t.toLowerCase() : ""); - if (!tags.length || !tags.some(t => - typeof t === "string" && t.toLowerCase().includes(filterTag) - )) continue; + // ✅ MODIFIKASI: Pengecekan filterTags + if (filterTags.length > 0) { + const isTagMatch = lowerCaseTags.some(opTag => + filterTags.some(fTag => opTag.includes(fTag)) + ); + + if (!isTagMatch) continue; + } else if (tags.length === 0) { + // Jika tidak ada filter, hanya proses operation yang memiliki tags + continue; + } try { const tool = createToolFromOperation(path, method, operation, tags); @@ -354,8 +375,11 @@ function cleanToolName(name: string): string { /** * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP + * * @param url URL of the OpenAPI spec. + * @param filterTag A string or array of strings. Operations must match at least one tag + * (case-insensitive partial match). */ -export async function getMcpTools(url: string, filterTag: string): Promise { +export async function getMcpTools(url: string, filterTag: string | string[]): Promise { try { console.log(`Fetching OpenAPI spec from: ${url}`); @@ -369,12 +393,12 @@ export async function getMcpTools(url: string, filterTag: string): Promise(); // - preserves function name loadTools (do not rename) // - adds TTL, forceRefresh handling, and robust error handling // ====================================================== -async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = false): Promise { - const cacheKey = `${openapiUrl}::${filterTag || ""}`; +async function loadTools(openapiUrl: string, filterTag: string | string[], forceRefresh = false): Promise { + // Gunakan JSON.stringify untuk membuat cacheKey yang stabil dari array + const filterKey = Array.isArray(filterTag) ? filterTag.slice().sort().join(":") : (filterTag || "all"); + const cacheKey = `${openapiUrl}::${filterKey}`; try { const cached = toolsCache.get(cacheKey); @@ -30,8 +33,12 @@ async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = f return cached.tools; } - console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} ...`); - const fetched = await getMcpTools(openapiUrl, filterTag); + console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} with filters: ${filterKey}...`); + + // Cek jika filternya adalah 'all', kirim array kosong atau 'all' ke converter + const tagsToFilter = (filterKey === "all" || filterKey === "") ? [] : filterTag; + + const fetched = await getMcpTools(openapiUrl, tagsToFilter); console.log(`[MCP] ✅ Loaded ${fetched.length} tools`); if (fetched.length > 0) { @@ -376,6 +383,44 @@ async function handleMCPRequest( } } +// ====================================================== +// Helper untuk mengambil semua tags unik dari OpenAPI +// ====================================================== +async function fetchAllTags(openapiUrl: string): Promise { + if (!openapiUrl) return []; + + try { + const response = await fetch(openapiUrl); + if (!response.ok) { + console.warn(`Failed to fetch OpenAPI spec for tags: ${response.status}`); + return []; + } + + const openApiJson = await response.json(); + const paths = openApiJson.paths || {}; + const tags = new Set(); + + for (const methods of Object.values(paths)) { + if (typeof methods !== "object" || methods === null) continue; + + for (const operation of Object.values(methods)) { + if (Array.isArray(operation.tags)) { + operation.tags.forEach((tag: any) => { + if (typeof tag === "string" && tag.trim()) { + tags.add(tag.trim()); + } + }); + } + } + } + return Array.from(tags).sort(); + } catch (err) { + console.error(`Error fetching or parsing tags from ${openapiUrl}:`, err); + return []; + } +} + + // ====================================================== // MCP TRIGGER NODE // - preserves class name OpenapiMcpServer @@ -418,12 +463,21 @@ export class OpenapiMcpServer implements INodeType { default: "", placeholder: "https://example.com/openapi.json", }, + // ✅ PERUBAHAN: Default Filter diubah menjadi multiSelect { - displayName: "Default Filter", + displayName: "Default Filters (Tags)", name: "defaultFilter", - type: "string", - default: "", - placeholder: "mcp | tag", + type: "options", // Diubah dari 'string' ke 'multiSelect' + default: ["all"], // Nilai default diubah menjadi array dengan 'all' + description: 'Pilih tag/kategori tool yang ingin di-expose. Default: All.', + options: [ + // Opsi ini akan diisi secara dinamis oleh loadOptions + ], + typeOptions: { + loadOptionsMethod: 'loadTagsForFilter', // Metode baru untuk memuat tags + refreshOnOpen: true, + multiSelect: true, + }, }, { displayName: 'Available Tools (auto-refresh)', @@ -444,16 +498,42 @@ export class OpenapiMcpServer implements INodeType { // ================================================== methods = { loadOptions: { + // ✅ METODE BARU: Memuat daftar tags yang tersedia + async loadTagsForFilter(this: ILoadOptionsFunctions): Promise { + const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; + + if (!openapiUrl) { + return [{ name: "❌ No OpenAPI URL provided", value: "all" }]; + } + + const tags = await fetchAllTags(openapiUrl); + + return [ + { name: "All Tools (default)", value: "all" }, + ...tags.map((tag) => ({ + name: tag, + value: tag, + description: `Filter tools by tag: ${tag}`, + })), + ]; + }, + async refreshToolList(this: ILoadOptionsFunctions): Promise { const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; - const filterTag = this.getNodeParameter("defaultFilter", 0) as string; + // ✅ Ambil nilai sebagai array (multiSelect) + const filterTags = this.getNodeParameter("defaultFilter", 0) as string[]; if (!openapiUrl) { return [{ name: "❌ No OpenAPI URL provided", value: "" }]; } - + + // Jika "all" dipilih (atau tidak ada filter), kirim "all" + const filterValue = (filterTags.includes("all") || filterTags.length === 0) + ? "all" + : filterTags; + // force refresh when user opens selector explicitly - const tools = await loadTools(openapiUrl, filterTag, true); + const tools = await loadTools(openapiUrl, filterValue, true); return [ { name: "All Tools", value: "all" }, @@ -472,10 +552,16 @@ export class OpenapiMcpServer implements INodeType { // ================================================== async webhook(this: IWebhookFunctions): Promise { const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; - const filterTag = this.getNodeParameter("defaultFilter", 0) as string; + // ✅ Ambil nilai sebagai array (multiSelect) + const filterTags = this.getNodeParameter("defaultFilter", 0) as string[]; + // Jika "all" dipilih (atau tidak ada filter), kirim "all" + const filterValue = (filterTags.includes("all") || filterTags.length === 0) + ? "all" + : filterTags; + // Use cached tools by default — non-blocking and faster - const tools = await loadTools(openapiUrl, filterTag, false); + const tools = await loadTools(openapiUrl, filterValue, false); const creds = await this.getCredentials("openapiMcpServerCredentials") as { baseUrl: string; @@ -523,4 +609,4 @@ export class OpenapiMcpServer implements INodeType { webhookResponse: single, }; } -} +} \ No newline at end of file diff --git a/src/package.json b/src/package.json index a308f91..99db561 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-openapi-mcp-server", - "version": "1.1.33", + "version": "1.1.34", "keywords": [ "n8n", "n8n-nodes"