diff --git a/OpenapiMcpServer.ts.v2.txt b/OpenapiMcpServer.ts.v2.txt new file mode 100644 index 0000000..e98b571 --- /dev/null +++ b/OpenapiMcpServer.ts.v2.txt @@ -0,0 +1,423 @@ +import { + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; +import { getMcpTools } from "../lib/mcp_tool_convert"; + +// ====================================================== +// Cache tools per URL +// ====================================================== +const toolsCache = new Map(); + +// ====================================================== +// Load OpenAPI → MCP Tools +// ====================================================== +async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = false): Promise { + const cacheKey = `${openapiUrl}::${filterTag}`; + + if (!forceRefresh && toolsCache.has(cacheKey)) { + return toolsCache.get(cacheKey)!; + } + + console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} ...`); + const fetched = await getMcpTools(openapiUrl, filterTag); + + console.log(`[MCP] ✅ Loaded ${fetched.length} tools`); + if (fetched.length > 0) { + console.log(`[MCP] Tools: ${fetched.map((t: any) => t.name).join(", ")}`); + } + + toolsCache.set(cacheKey, fetched); + return fetched; +} + +// ====================================================== +// JSON-RPC Types +// ====================================================== +type JSONRPCRequest = { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: any; + credentials?: any; +}; + +type JSONRPCResponse = { + jsonrpc: "2.0"; + id: string | number; + result?: any; + error?: { code: number; message: string; data?: any }; +}; + +// ====================================================== +// EXECUTE TOOL — SUPPORT PATH, QUERY, HEADER, BODY, COOKIE +// ====================================================== +async function executeTool( + tool: any, + args: Record = {}, + baseUrl: string, + token?: string +) { + const x = tool["x-props"] || {}; + const method = (x.method || "GET").toUpperCase(); + let path = x.path || `/${tool.name}`; + + const query: Record = {}; + const headers: Record = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + let bodyPayload: any = undefined; + + // ====================================================== + // Pisahkan args berdasarkan OpenAPI parameter location + // ====================================================== + if (Array.isArray(x.parameters)) { + for (const p of x.parameters) { + const name = p.name; + const value = args[name]; + if (value === undefined) continue; + + switch (p.in) { + case "path": + path = path.replace(`{${name}}`, encodeURIComponent(value)); + break; + + case "query": + query[name] = value; + break; + + case "header": + headers[name] = value; + break; + + case "cookie": + headers["Cookie"] = `${name}=${value}`; + break; + + case "body": + case "requestBody": + bodyPayload = value; + break; + + default: + break; + } + } + } else { + // fallback → semua args dianggap body + bodyPayload = args; + } + + // ====================================================== + // Build Final URL + // ====================================================== + let url = `${baseUrl}${path}`; + const qs = new URLSearchParams(query).toString(); + if (qs) url += `?${qs}`; + + // ====================================================== + // Build Request Options + // ====================================================== + const opts: RequestInit = { method, headers }; + if (["POST", "PUT", "PATCH", "DELETE"].includes(method) && bodyPayload !== undefined) { + opts.body = JSON.stringify(bodyPayload); + } + + console.log(`[MCP] → Calling ${method} ${url}`); + + const res = await fetch(url, opts); + const contentType = res.headers.get("content-type") || ""; + const data = contentType.includes("application/json") + ? await res.json() + : await res.text(); + + return { + success: res.ok, + status: res.status, + method, + url, + path, + data, + }; +} + +// ====================================================== +// JSON-RPC Handler +// ====================================================== +async function handleMCPRequest( + request: JSONRPCRequest, + tools: any[] +): Promise { + const { id, method, params, credentials } = request; + + switch (method) { + case "initialize": + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "n8n-mcp-server", version: "1.0.0" }, + }, + }; + + case "tools/list": + return { + jsonrpc: "2.0", + id, + result: { + tools: tools.map((t) => { + const inputSchema = + typeof t.inputSchema === "object" && t.inputSchema?.type === "object" + ? t.inputSchema + : { + type: "object", + properties: {}, + required: [], + }; + + return { + name: t.name, + description: t.description || "No description provided", + inputSchema, + "x-props": t["x-props"], + }; + }), + }, + }; + + case "tools/call": { + const toolName = params?.name; + const tool = tools.find((t) => t.name === toolName); + + if (!tool) { + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Tool '${toolName}' not found` }, + }; + } + + // Converter MCP content yang valid + function convertToMcpContent(data: any) { + // Jika string → text + if (typeof data === "string") { + return { + type: "text", + text: data, + }; + } + + // Jika kirim tipe khusus image + if (data?.__mcp_type === "image") { + return { + type: "image", + data: data.base64, + mimeType: data.mimeType || "image/png", + }; + } + + // Jika audio + if (data?.__mcp_type === "audio") { + return { + type: "audio", + data: data.base64, + mimeType: data.mimeType || "audio/mpeg", + }; + } + + // Jika resource link + if (data?.__mcp_type === "resource_link") { + return { + type: "resource_link", + name: data.name || "resource", + uri: data.uri, + }; + } + + // Jika object biasa → jadikan resource + if (typeof data === "object") { + return { + type: "resource", + resource: data, + }; + } + + // fallback → text stringified + return { + type: "text", + text: JSON.stringify(data, null, 2), + }; + } + + try { + const baseUrl = credentials?.baseUrl; + const token = credentials?.token; + + const result = await executeTool( + tool, + params?.arguments || {}, + baseUrl, + token + ); + + const raw = result.data?.data ?? result.data; + + return { + jsonrpc: "2.0", + id, + result: { + content: [convertToMcpContent(raw)], + }, + }; + } catch (err: any) { + return { + jsonrpc: "2.0", + id, + error: { code: -32603, message: err.message }, + }; + } + } + + + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + + default: + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method '${method}' not found` }, + }; + } +} + +// ====================================================== +// MCP TRIGGER NODE +// ====================================================== +export class OpenapiMcpServer implements INodeType { + description: INodeTypeDescription = { + displayName: 'OpenAPI MCP Server', + name: 'openapiMcpServer', + group: ['trigger'], + version: 1, + description: 'Runs an MCP Server inside n8n', + icon: 'file:icon.svg', + defaults: { name: 'OpenAPI MCP Server' }, + credentials: [ + { name: "openapiMcpServerCredentials", required: true }, + ], + inputs: [], + outputs: ['main'], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: '={{$parameter["path"]}}', + }, + ], + properties: [ + { + displayName: "Path", + name: "path", + type: "string", + default: "mcp", + }, + { + displayName: "OpenAPI URL", + name: "openapiUrl", + type: "string", + default: "", + placeholder: "https://example.com/openapi.json", + }, + { + displayName: "Default Filter", + name: "defaultFilter", + type: "string", + default: "", + placeholder: "mcp | tag", + }, + { + displayName: 'Available Tools (auto-refresh)', + name: 'toolList', + type: 'options', + typeOptions: { + loadOptionsMethod: 'refreshToolList', + refreshOnOpen: true, + }, + default: 'all', + description: 'Daftar tools yang berhasil dimuat dari OpenAPI', + }, + ], + }; + + // ================================================== + // LoadOptions + // ================================================== + methods = { + loadOptions: { + async refreshToolList(this: ILoadOptionsFunctions): Promise { + const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; + const filterTag = this.getNodeParameter("defaultFilter", 0) as string; + + if (!openapiUrl) { + return [{ name: "❌ No OpenAPI URL provided", value: "" }]; + } + + const tools = await loadTools(openapiUrl, filterTag, true); + + return [ + { name: "All Tools", value: "all" }, + ...tools.map((t) => ({ + name: t.name, + value: t.name, + description: t.description, + })), + ]; + }, + }, + }; + + // ================================================== + // Webhook Handler + // ================================================== + async webhook(this: IWebhookFunctions): Promise { + const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; + const filterTag = this.getNodeParameter("defaultFilter", 0) as string; + + const tools = await loadTools(openapiUrl, filterTag, true); + + const creds = await this.getCredentials("openapiMcpServerCredentials") as { + baseUrl: string; + token: string; + }; + + const body = this.getBodyData(); + + if (Array.isArray(body)) { + const responses = body.map((r) => + handleMCPRequest({ ...r, credentials: creds }, tools) + ); + return { + webhookResponse: await Promise.all(responses), + }; + } + + const single = await handleMCPRequest( + { ...(body as JSONRPCRequest), credentials: creds }, + tools + ); + + return { + webhookResponse: single, + }; + } +} diff --git a/mcp_tool_convert.ts.txt b/mcp_tool_convert.ts.txt new file mode 100644 index 0000000..640d1c9 --- /dev/null +++ b/mcp_tool_convert.ts.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/package.json b/package.json index 2e61788..f8002c9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "version:update": "bun bin/version_update.ts", "publish": "bun bin/version_update.ts && bun bin/build.ts && bun bin/publish.ts" }, - "dependencies": {}, + "dependencies": { + "lodash": "^4.17.21" + }, "devDependencies": { "express": "^5.1.0", "@types/bun": "latest", @@ -20,8 +22,7 @@ "n8n-workflow": "^1.116.0", "nock": "^14.0.10", "ssh2": "^1.17.0", - "dedent": "^1.7.0", - "lodash": "^4.17.21" + "dedent": "^1.7.0" }, "peerDependencies": { "typescript": "^5" diff --git a/src/lib/mcp_tool_convert.ts b/src/lib/mcp_tool_convert.ts index 640d1c9..9abc972 100644 --- a/src/lib/mcp_tool_convert.ts +++ b/src/lib/mcp_tool_convert.ts @@ -1,5 +1,15 @@ +// mcp_tool_convert.ts import _ from "lodash"; +/** + * This file: + * - preserves exported function names: convertOpenApiToMcpTools and getMcpTools + * - improves resilience when parsing OpenAPI objects + * - emits x-props.parameters array so executeTool can act correctly + * - ensures requestBody is represented as a synthetic `body` parameter when necessary + */ + +// == Types interface McpTool { name: string; description: string; @@ -11,22 +21,24 @@ interface McpTool { tag?: string; deprecated?: boolean; summary?: string; + parameters?: any[]; // added to communicate param locations to executor }; } /** * Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. + * - filterTag is matched case-insensitively against operation tags (substring) */ 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; @@ -34,20 +46,19 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M 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; + // If filterTag provided, require at least one tag to include it (case-insensitive) + if (filterTag && (!tags.length || !tags.some(t => typeof t === "string" && t.toLowerCase().includes(filterTag.toLowerCase())))) { + continue; + } try { const tool = createToolFromOperation(path, method, operation, tags); @@ -65,7 +76,8 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M } /** - * Buat MCP tool dari operation OpenAPI + * Create MCP tool from an OpenAPI operation. + * - Ensures x-props.parameters exists and describes path/query/header/cookie/requestBody */ function createToolFromOperation( path: string, @@ -87,16 +99,69 @@ function createToolFromOperation( operation.summary || `Execute ${method.toUpperCase()} ${path}`; - // ✅ Extract schema berdasarkan method + // Build parameters array for executor + const parameters: any[] = []; + + if (Array.isArray(operation.parameters)) { + for (const p of operation.parameters) { + try { + // copy essential fields + const paramEntry: any = { + name: p.name, + in: p.in, + required: !!p.required, + description: p.description, + schema: p.schema || { type: "string" }, + }; + parameters.push(paramEntry); + } catch (err) { + console.warn("Skipping invalid parameter:", p, err); + } + } + } + + // If requestBody exists, synthesize a single `body` parameter so the executor can pick it up. + // We do not try to expand complex requestBody schemas into multiple parameters here — inputSchema covers that. + if (operation.requestBody && typeof operation.requestBody === "object") { + // prefer application/json schema, fallback to first available + const content = operation.requestBody.content || {}; + let schemaCandidate: any = null; + const preferred = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"]; + for (const c of preferred) { + if (content[c]?.schema) { + schemaCandidate = content[c].schema; + break; + } + } + if (!schemaCandidate) { + const entries = Object.entries(content); + if (entries.length > 0 && (entries[0] as any)[1]?.schema) { + schemaCandidate = (entries[0] as any)[1].schema; + } + } + + // Add synthetic body param (name "body") + parameters.push({ + name: "body", + in: "requestBody", + required: !!operation.requestBody.required, + schema: schemaCandidate || { type: "object" }, + description: operation.requestBody.description || "Request body", + }); + } + + // Extract input schema for UI (query/path/header -> properties OR requestBody schema) let schema; - if (method.toLowerCase() === "get") { - // ✅ Untuk GET, ambil dari parameters (query/path) + if (method.toLowerCase() === "get" || method.toLowerCase() === "delete" || method.toLowerCase() === "head") { schema = extractParametersSchema(operation.parameters || []); } else { - // ✅ Untuk POST/PUT/etc, ambil dari requestBody schema = extractRequestBodySchema(operation); + // if no requestBody but parameters exist, fall back to parameters schema + if (!schema) { + schema = extractParametersSchema(operation.parameters || []); + } } - + const inputSchema = createInputSchema(schema); return { @@ -109,6 +174,7 @@ function createToolFromOperation( tag: tags[0], deprecated: operation.deprecated || false, summary: operation.summary, + parameters, // executor will rely on this }, inputSchema, }; @@ -119,9 +185,10 @@ function createToolFromOperation( } /** - * Extract schema dari parameters (untuk GET requests) + * Extract schema dari parameters (untuk GET/DELETE requests) + * - returns null if no parameters */ -function extractParametersSchema(parameters: any[]): any { +function extractParametersSchema(parameters: any[]): any | null { if (!Array.isArray(parameters) || parameters.length === 0) { return null; } @@ -132,7 +199,7 @@ function extractParametersSchema(parameters: any[]): any { for (const param of parameters) { if (!param || typeof param !== "object") continue; - // ✅ Support path, query, dan header parameters + // Support path, query, dan header parameters if (["path", "query", "header"].includes(param.in)) { const paramName = param.name; if (!paramName || typeof paramName !== "string") continue; @@ -142,7 +209,7 @@ function extractParametersSchema(parameters: any[]): any { description: param.description || `${param.in} parameter: ${paramName}`, }; - // ✅ Copy field tambahan dari schema + // copy allowed fields if (param.schema) { const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"]; for (const field of allowedFields) { @@ -171,8 +238,9 @@ function extractParametersSchema(parameters: any[]): any { /** * Extract schema dari requestBody (untuk POST/PUT/etc requests) + * - prefers application/json, handles form-data, urlencoded fallbacks */ -function extractRequestBodySchema(operation: any): any { +function extractRequestBodySchema(operation: any): any | null { if (!operation.requestBody?.content) { return null; } @@ -203,6 +271,7 @@ function extractRequestBodySchema(operation: any): any { /** * Buat input schema yang valid untuk MCP + * - preserves optional flags, required semantics, and nested properties */ function createInputSchema(schema: any): any { const defaultSchema = { @@ -229,11 +298,10 @@ function createInputSchema(schema: any): any { if (cleanProp) { properties[key] = cleanProp; - // ✅ PERBAIKAN: Check optional flag dengan benar + // Check optional flag properly 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); } @@ -243,6 +311,9 @@ function createInputSchema(schema: any): any { continue; } } + } else if (schema.type === "array" && schema.items) { + // represent top-level array as object with items property to keep inputSchema shape predictable + properties["items"] = cleanProperty(schema.items) || { type: "string" }; } return { @@ -259,6 +330,7 @@ function createInputSchema(schema: any): any { /** * Bersihkan property dari field custom + * - preserves nested structures, arrays, and combiners (oneOf/anyOf/allOf) */ function cleanProperty(prop: any): any | null { if (!prop || typeof prop !== "object") { @@ -301,7 +373,7 @@ function cleanProperty(prop: any): any | null { cleaned.properties[key] = cleanedNested; } } - + if (Array.isArray(prop.required)) { cleaned.required = prop.required.filter((r: any) => typeof r === "string"); } @@ -342,7 +414,7 @@ function cleanToolName(name: string): string { .replace(/[^a-zA-Z0-9_]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") - // ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate) + // keep lowercase and stable .toLowerCase() || "unnamed_tool"; } catch (error) { @@ -351,30 +423,29 @@ function cleanToolName(name: string): string { } } - /** * Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP + * - preserves exported name getMcpTools + * - robust error handling and console diagnostics */ 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/nodes/OpenapiMcpServer.ts b/src/nodes/OpenapiMcpServer.ts index e98b571..5102858 100644 --- a/src/nodes/OpenapiMcpServer.ts +++ b/src/nodes/OpenapiMcpServer.ts @@ -1,3 +1,4 @@ +// OpenapiMcpServer.ts import { INodeType, INodeTypeDescription, @@ -9,30 +10,46 @@ import { import { getMcpTools } from "../lib/mcp_tool_convert"; // ====================================================== -// Cache tools per URL +// Cache tools per URL (with TTL & safe structure) // ====================================================== -const toolsCache = new Map(); +type CachedTools = { timestamp: number; tools: any[] }; +const TOOLS_CACHE_TTL_MS = 5 * 60_000; // 5 minutes +const toolsCache = new Map(); // ====================================================== // Load OpenAPI → MCP Tools +// - 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}`; + const cacheKey = `${openapiUrl}::${filterTag || ""}`; - if (!forceRefresh && toolsCache.has(cacheKey)) { - return toolsCache.get(cacheKey)!; + try { + const cached = toolsCache.get(cacheKey); + if (!forceRefresh && cached && (Date.now() - cached.timestamp) < TOOLS_CACHE_TTL_MS) { + return cached.tools; + } + + console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} ...`); + const fetched = await getMcpTools(openapiUrl, filterTag); + + console.log(`[MCP] ✅ Loaded ${fetched.length} tools`); + if (fetched.length > 0) { + console.log(`[MCP] Tools: ${fetched.map((t: any) => t.name).join(", ")}`); + } + + toolsCache.set(cacheKey, { timestamp: Date.now(), tools: fetched }); + return fetched; + } catch (err) { + console.error(`[MCP] Failed to load tools from ${openapiUrl}:`, err); + // On failure, if cache exists return stale to avoid complete outage + const stale = toolsCache.get(cacheKey); + if (stale) { + console.warn(`[MCP] Returning stale cached tools for ${cacheKey}`); + return stale.tools; + } + throw err; } - - console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} ...`); - const fetched = await getMcpTools(openapiUrl, filterTag); - - console.log(`[MCP] ✅ Loaded ${fetched.length} tools`); - if (fetched.length > 0) { - console.log(`[MCP] Tools: ${fetched.map((t: any) => t.name).join(", ")}`); - } - - toolsCache.set(cacheKey, fetched); - return fetched; } // ====================================================== @@ -55,6 +72,9 @@ type JSONRPCResponse = { // ====================================================== // EXECUTE TOOL — SUPPORT PATH, QUERY, HEADER, BODY, COOKIE +// - preserves function name executeTool +// - fixes cookie accumulation, query-array handling, path param safety, +// requestBody handling based on x.parameters + synthetic body param // ====================================================== async function executeTool( tool: any, @@ -66,47 +86,73 @@ async function executeTool( const method = (x.method || "GET").toUpperCase(); let path = x.path || `/${tool.name}`; + if (!baseUrl) { + throw new Error("Missing baseUrl in credentials"); + } + const query: Record = {}; const headers: Record = { + // default content-type; may be overridden by header params or request "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }; + // Support multiple cookies: accumulate into array, then join + const cookies: string[] = []; + let bodyPayload: any = undefined; - // ====================================================== - // Pisahkan args berdasarkan OpenAPI parameter location - // ====================================================== + // x.parameters may have been produced by converter. + // Expected shape: [{ name, in, schema?, required? }] if (Array.isArray(x.parameters)) { for (const p of x.parameters) { const name = p.name; - const value = args[name]; + // allow alias e.g. body parameter named "__body" or "body" + const value = args?.[name]; + + // If param not provided, skip (unless required, leave to tool to validate later) if (value === undefined) continue; - switch (p.in) { - case "path": - path = path.replace(`{${name}}`, encodeURIComponent(value)); - break; + try { + switch (p.in) { + case "path": + // Safely replace only if placeholder exists + if (path.includes(`{${name}}`)) { + path = path.replace(new RegExp(`{${name}}`, "g"), encodeURIComponent(String(value))); + } else { + // If path doesn't contain placeholder, append as query fallback + query[name] = value; + } + break; - case "query": - query[name] = value; - break; + case "query": + // handle array correctly: produce repeated keys for URLSearchParams + // Store as-is and handle later when building QS + query[name] = value; + break; - case "header": - headers[name] = value; - break; + case "header": + headers[name] = value; + break; - case "cookie": - headers["Cookie"] = `${name}=${value}`; - break; + case "cookie": + cookies.push(`${name}=${value}`); + break; - case "body": - case "requestBody": - bodyPayload = value; - break; + case "body": + case "requestBody": + // prefer explicit body param; overwrite if multiple present + bodyPayload = value; + break; - default: - break; + default: + // unknown param location — put into body as fallback + bodyPayload = bodyPayload ?? {}; + bodyPayload[name] = value; + break; + } + } catch (err) { + console.warn(`[MCP] Skipping parameter ${name} due to error:`, err); } } } else { @@ -114,26 +160,67 @@ async function executeTool( bodyPayload = args; } + if (cookies.length > 0) { + headers["Cookie"] = cookies.join("; "); + } + // ====================================================== // Build Final URL // ====================================================== - let url = `${baseUrl}${path}`; - const qs = new URLSearchParams(query).toString(); - if (qs) url += `?${qs}`; + // Ensure baseUrl doesn't end with duplicate slashes + const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + let url = `${normalizedBase}${normalizedPath}`; + + // Build query string with repeated keys if array provided + const qsParts: string[] = []; + for (const [k, v] of Object.entries(query)) { + if (v === undefined || v === null) continue; + if (Array.isArray(v)) { + for (const item of v) { + qsParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(item))}`); + } + } else if (typeof v === "object") { + // JSON-encode objects as value + qsParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`); + } else { + qsParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + } + } + if (qsParts.length) url += `?${qsParts.join("&")}`; // ====================================================== // Build Request Options // ====================================================== - const opts: RequestInit = { method, headers }; + const opts: RequestInit & { headers: Record } = { method, headers }; + // If content-type is form data, adjust accordingly (converter could mark) + const contentType = headers["Content-Type"]?.toLowerCase() ?? ""; + if (["POST", "PUT", "PATCH", "DELETE"].includes(method) && bodyPayload !== undefined) { - opts.body = JSON.stringify(bodyPayload); + // If requestBody is already a FormData-like or flagged in x (converter support), + // caller could pass a special object { __formdata: true, entries: [...] } — support minimal + if (bodyPayload && bodyPayload.__formdata === true && Array.isArray(bodyPayload.entries)) { + const form = new FormData(); + for (const [k, v] of bodyPayload.entries) { + form.append(k, v); + } + // Let fetch set Content-Type with boundary + delete opts.headers["Content-Type"]; + opts.body = (form as any) as BodyInit; + } else if (contentType.includes("application/x-www-form-urlencoded")) { + opts.body = new URLSearchParams(bodyPayload).toString(); + } else { + // default JSON + opts.body = JSON.stringify(bodyPayload); + } } console.log(`[MCP] → Calling ${method} ${url}`); const res = await fetch(url, opts); - const contentType = res.headers.get("content-type") || ""; - const data = contentType.includes("application/json") + const resContentType = (res.headers.get("content-type") || "").toLowerCase(); + + const data = resContentType.includes("application/json") ? await res.json() : await res.text(); @@ -144,11 +231,14 @@ async function executeTool( url, path, data, + headers: res.headers, // keep for diagnostics }; } // ====================================================== // JSON-RPC Handler +// - preserves handleMCPRequest name +// - improved error reporting, robust content conversion, batch safety // ====================================================== async function handleMCPRequest( request: JSONRPCRequest, @@ -156,6 +246,13 @@ async function handleMCPRequest( ): Promise { const { id, method, params, credentials } = request; + // helper to create consistent error responses with optional debug data + const makeError = (code: number, message: string, data?: any) => ({ + jsonrpc: "2.0", + id, + error: { code, message, data }, + }); + switch (method) { case "initialize": return { @@ -198,11 +295,7 @@ async function handleMCPRequest( const tool = tools.find((t) => t.name === toolName); if (!tool) { - return { - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Tool '${toolName}' not found` }, - }; + return makeError(-32601, `Tool '${toolName}' not found`) as JSONRPCResponse; } // Converter MCP content yang valid @@ -215,8 +308,8 @@ async function handleMCPRequest( }; } - // Jika kirim tipe khusus image - if (data?.__mcp_type === "image") { + // Jika kirim tipe khusus image (base64) + if (data?.__mcp_type === "image" && typeof data.base64 === "string") { return { type: "image", data: data.base64, @@ -225,7 +318,7 @@ async function handleMCPRequest( } // Jika audio - if (data?.__mcp_type === "audio") { + if (data?.__mcp_type === "audio" && typeof data.base64 === "string") { return { type: "audio", data: data.base64, @@ -234,7 +327,7 @@ async function handleMCPRequest( } // Jika resource link - if (data?.__mcp_type === "resource_link") { + if (data?.__mcp_type === "resource_link" && data.uri) { return { type: "resource_link", name: data.name || "resource", @@ -242,11 +335,12 @@ async function handleMCPRequest( }; } - // Jika object biasa → jadikan resource + // Jika object biasa → jadikan resource (wrap arrays into object) if (typeof data === "object") { + const resource = Array.isArray(data) ? { items: data } : data; return { type: "resource", - resource: data, + resource, }; } @@ -278,29 +372,26 @@ async function handleMCPRequest( }, }; } catch (err: any) { - return { - jsonrpc: "2.0", - id, - error: { code: -32603, message: err.message }, - }; + // return error with message and minimal debug info (avoid leaking secrets) + const debug = { message: err?.message, stack: err?.stack?.split("\n").slice(0, 5) }; + + return makeError(-32603, err?.message || "Internal error", debug) as JSONRPCResponse; } } - case "ping": return { jsonrpc: "2.0", id, result: {} }; default: - return { - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Method '${method}' not found` }, - }; + return makeError(-32601, `Method '${method}' not found`) as JSONRPCResponse; } } // ====================================================== // MCP TRIGGER NODE +// - preserves class name OpenapiMcpServer +// - avoids forcing refresh on every webhook call (uses cache by default) +// - safer batch handling (Promise.allSettled) to return array of results // ====================================================== export class OpenapiMcpServer implements INodeType { description: INodeTypeDescription = { @@ -372,6 +463,7 @@ export class OpenapiMcpServer implements INodeType { return [{ name: "❌ No OpenAPI URL provided", value: "" }]; } + // force refresh when user opens selector explicitly const tools = await loadTools(openapiUrl, filterTag, true); return [ @@ -393,21 +485,43 @@ export class OpenapiMcpServer implements INodeType { const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; const filterTag = this.getNodeParameter("defaultFilter", 0) as string; - const tools = await loadTools(openapiUrl, filterTag, true); + // Use cached tools by default — non-blocking and faster + const tools = await loadTools(openapiUrl, filterTag, false); const creds = await this.getCredentials("openapiMcpServerCredentials") as { baseUrl: string; token: string; }; + if (!creds || !creds.baseUrl) { + throw new Error("Missing openapiMcpServerCredentials or baseUrl"); + } + const body = this.getBodyData(); + // Batch handling: use Promise.allSettled and return array of results if (Array.isArray(body)) { - const responses = body.map((r) => + const promises = body.map((r) => handleMCPRequest({ ...r, credentials: creds }, tools) ); + const settled = await Promise.allSettled(promises); + + // Normalize to either results or errors in MCP shape + const responses = settled.map((s) => { + if (s.status === "fulfilled") return s.value; + return { + jsonrpc: "2.0", + id: "error", + error: { + code: -32000, + message: "Unhandled handler error", + data: s.reason?.message ?? String(s.reason), + }, + } as JSONRPCResponse; + }); + return { - webhookResponse: await Promise.all(responses), + webhookResponse: responses, }; } diff --git a/src/package.json b/src/package.json index 2946e11..b30e559 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-openapi-mcp-server", - "version": "1.1.26", + "version": "1.1.27", "keywords": [ "n8n", "n8n-nodes"