diff --git a/src/nodes/OpenApiNode.node.ts b/src/nodes/OpenApiNode.node.ts index ee36b1a..d3fa21a 100644 --- a/src/nodes/OpenApiNode.node.ts +++ b/src/nodes/OpenApiNode.node.ts @@ -1,361 +1,334 @@ -// mcp_tool_convert.ts -import _ from "lodash"; +import type { + INodeType, + INodeTypeDescription, + IExecuteFunctions, + ILoadOptionsFunctions, + INodePropertyOptions, +} from "n8n-workflow"; +import axios from "axios"; -/** - * ============================ - * Types - * ============================ - */ -interface McpTool { - name: string; - description: string; - inputSchema: any; - "x-props": { - method: string; - path: string; - operationId?: string; - tag?: string; - deprecated?: boolean; - summary?: string; - parameters?: any[]; +interface OpenAPISpec { + paths: Record; + components?: Record; + servers?: Array<{ url: string }>; +} + +const openApiCache: Map = new Map(); + +async function loadOpenAPISpecCached(url: string): Promise { + if (openApiCache.has(url)) return openApiCache.get(url)!; + const res = await axios.get(url, { timeout: 20000 }); + const spec: OpenAPISpec = res.data; + openApiCache.set(url, spec); + return spec; +} + +function getDefaultValue(type: string | undefined, schema?: any): any { + if (schema?.example !== undefined) return schema.example; + if (schema?.default !== undefined) return schema.default; + switch (type) { + case "string": return ""; + case "number": + case "integer": return 0; + case "boolean": return false; + case "array": return []; + case "object": return {}; + default: return null; + } +} + +function resolveRef(obj: any, spec: OpenAPISpec): any { + if (!obj || typeof obj !== "object") return obj; + if (obj.$ref && typeof obj.$ref === "string" && obj.$ref.startsWith("#/")) { + const refPath = obj.$ref.replace(/^#\//, "").split("/"); + let cur: any = spec; + for (const seg of refPath) cur = cur?.[seg]; + return cur || obj; + } + return obj; +} + +export class OpenApiNode implements INodeType { + description: INodeTypeDescription = { + displayName: "OpenApiNode - Dynamic OpenAPI", + name: "openApiNode", + icon: "file:icon.svg", + group: ["transform"], + version: 1, + subtitle: "={{$parameter['operation'] || 'select operation'}}", + description: "Dynamic UI n8n node generated from OpenAPI", + defaults: { name: "OpenApiNode" }, + inputs: ["main"], + outputs: ["main"], + credentials: [ + { name: "openApiNodeApi", required: false }, + ], + properties: [ + { + displayName: "OpenAPI JSON URL", + name: "openapiUrl", + type: "string", + default: "", + required: true, + }, + { + displayName: "Operation", + name: "operation", + type: "options", + typeOptions: { + loadOptionsMethod: "loadOperations", + loadOptionsDependsOn: ["openapiUrl"], + }, + default: "", + required: true, + }, + { + displayName: "Parameters (Form)", + name: "parametersForm", + type: "fixedCollection", + placeholder: "Add Parameter", + typeOptions: { multipleValues: true }, + default: {}, + options: [ + { + name: "parameter", + displayName: "Parameter", + values: [ + { + displayName: "Name", + name: "name", + type: "options", + typeOptions: { + loadOptionsMethod: "loadParameterNames", + loadOptionsDependsOn: ["openapiUrl", "operation"], + }, + default: "", + }, + { + displayName: "In", + name: "in", + type: "options", + options: [ + { name: "query", value: "query" }, + { name: "path", value: "path" }, + { name: "header", value: "header" }, + { name: "body", value: "body" }, + ], + default: "query", + }, + { + displayName: "Value", + name: "value", + type: "string", + default: "", + }, + ], + }, + ], + }, + ], }; -} -/** - * ============================ - * Public: convertOpenApiToMcpTools - * ============================ - * Convert OpenAPI 3.x spec → MCP Tools - * - filterTag : match against operation tags - */ -export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] { - const tools: McpTool[] = []; + methods = { + loadOptions: { + async loadOperations(this: ILoadOptionsFunctions): Promise { + const url = (this.getNodeParameter("openapiUrl", "") as string).trim(); + if (!url) return [{ name: "Provide openapiUrl first", value: "" }]; + try { + const spec = await loadOpenAPISpecCached(url); + const ops: INodePropertyOptions[] = []; - if (!openApiJson || typeof openApiJson !== "object") { - console.warn("Invalid OpenAPI JSON"); - return tools; - } + for (const path of Object.keys(spec.paths || {})) { + const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { + const op = pathObj[method]; + const opId = + op.operationId || + `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; + const title = (op.summary || opId).toString(); + ops.push({ + name: `${title} — ${method.toUpperCase()} ${path}`, + value: opId, + }); + } + } + return ops; + } catch (err: any) { + return [{ name: `Error: ${err.message}`, value: "" }]; + } + }, - const paths = openApiJson.paths || {}; - if (Object.keys(paths).length === 0) { - console.warn("No paths found in OpenAPI spec"); - return tools; - } + async loadParameterNames(this: ILoadOptionsFunctions): Promise { + const url = (this.getNodeParameter("openapiUrl", "") as string).trim(); + const opId = (this.getNodeParameter("operation", "") as string).trim(); + if (!url || !opId) return [{ name: "Select operation first", value: "" }]; - for (const [path, methods] of Object.entries(paths)) { - if (!methods || typeof methods !== "object") continue; + try { + const spec = await loadOpenAPISpecCached(url); + const out: INodePropertyOptions[] = []; - for (const [method, operation] of Object.entries(methods)) { - const valid = ["get", "post", "put", "delete", "patch", "head", "options"]; - if (!valid.includes(method.toLowerCase())) continue; + outer: for (const path of Object.keys(spec.paths || {})) { + const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { + const op = pathObj[method]; + const id = + op.operationId || + `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; + if (id !== opId) continue; - if (!operation || typeof operation !== "object") continue; + const params = [ + ...(pathObj.parameters || []), + ...(op.parameters || []), + ]; - const tags = Array.isArray(operation.tags) ? operation.tags : []; + const seen = new Set(); - // Tag filter - if ( - filterTag && - (!tags.length || - !tags.some((t: string) => - t?.toLowerCase().includes(filterTag.toLowerCase()) - )) - ) { - continue; - } + for (const p of params) { + if (!seen.has(p.name)) { + seen.add(p.name); + out.push({ + name: `${p.name} (in=${p.in})`, + value: p.name, + }); + } + } - try { - const tool = createToolFromOperation(path, method, operation, tags); - if (tool) tools.push(tool); - } catch (err) { - console.error(`Error building tool for ${method.toUpperCase()} ${path}`, err); - } - } - } + const bodySchema = + op.requestBody?.content?.["application/json"]?.schema; + if (bodySchema) { + const resolved = resolveRef(bodySchema, spec); + const props = resolved.properties || {}; + for (const propName of Object.keys(props)) { + if (!seen.has(propName)) { + seen.add(propName); + out.push({ + name: `${propName} (body)`, + value: propName, + }); + } + } + } - return tools; -} + break outer; + } + } -/** - * ============================ - * Build Tool from Operation - * ============================ - */ -function createToolFromOperation( - path: string, - method: string, - operation: any, - tags: string[] -): McpTool | null { - const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool"; - const name = cleanToolName(rawName); - - if (name === "unnamed_tool") { - console.warn(`Invalid tool name: ${method} ${path}`); - return null; - } - - const description = - operation.description || - operation.summary || - `Execute ${method.toUpperCase()} ${path}`; - - // Build executor parameter array - const parameters: any[] = []; - - if (Array.isArray(operation.parameters)) { - for (const p of operation.parameters) { - if (!p || typeof p !== "object") continue; - - parameters.push({ - name: p.name, - in: p.in, - required: !!p.required, - description: p.description, - schema: p.schema || { type: "string" }, - }); - } - } - - // Synthetic requestBody param - if (operation.requestBody?.content) { - const schema = extractPreferredContentSchema(operation.requestBody.content); - - parameters.push({ - name: "body", - in: "requestBody", - required: !!operation.requestBody.required, - schema: schema || { type: "object" }, - description: operation.requestBody.description || "Request body", - }); - } - - // Build input schema - let schema: any = null; - - const lower = method.toLowerCase(); - if (["get", "delete", "head"].includes(lower)) { - schema = extractParametersSchema(operation.parameters || []); - } else { - schema = extractRequestBodySchema(operation) || - extractParametersSchema(operation.parameters || []); - } - - 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, - parameters, + return out; + } catch (err: any) { + return [{ name: `Error: ${err.message}`, value: "" }]; + } + }, }, - inputSchema, }; -} -/** - * ============================ - * Extract Preferred Content Schema - * ============================ - */ -function extractPreferredContentSchema(content: any): any { - if (!content) return null; + async execute(this: IExecuteFunctions) { + const items = this.getInputData(); + const returnData: any[] = []; - const preferred = [ - "application/json", - "multipart/form-data", - "application/x-www-form-urlencoded", - "text/plain", - ]; + const creds = (await this.getCredentials?.("openApiNodeApi")) as + | { baseUrl?: string; token?: string; apiKey?: string } + | undefined; - for (const type of preferred) { - if (content[type]?.schema) return content[type].schema; - } + for (let i = 0; i < items.length; i++) { + try { + const openapiUrl = this.getNodeParameter("openapiUrl", i, "") as string; + const operationId = this.getNodeParameter("operation", i, "") as string; + const spec = await loadOpenAPISpecCached(openapiUrl); - const first = Object.values(content)[0]; - return first?.schema || null; -} + let foundOp: any = null; + let foundPath = ""; + let foundMethod = ""; -/** - * ============================ - * Extract Parameter Schema (GET/DELETE) - * ============================ - */ -function extractParametersSchema(parameters: any[]): any | null { - if (!parameters.length) return null; + outer: for (const path of Object.keys(spec.paths || {})) { + const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { + const op = pathObj[method]; + const id = + op.operationId || + `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; + if (id === operationId) { + foundOp = op; + foundPath = path; + foundMethod = method.toLowerCase(); + break outer; + } + } + } - const properties: any = {}; - const required: string[] = []; + if (!foundOp) throw new Error("Operation not found"); - for (const param of parameters) { - if (!["path", "query", "header"].includes(param.in)) continue; + const paramsForm = this.getNodeParameter("parametersForm", i, {}) as any; + let queryParams: Record = {}; + let bodyParams: Record = {}; - const name = param.name; - if (!name) continue; + const pathParams = [ + ...(spec.paths[foundPath].parameters || []), + ...(foundOp.parameters || []), + ]; - const schema = param.schema || { type: "string" }; + if (paramsForm && Array.isArray(paramsForm.parameter)) { + for (const p of paramsForm.parameter) { + if (p.in === "query") queryParams[p.name] = p.value; + else if (p.in === "body") bodyParams[p.name] = p.value; + else if (p.in === "header") queryParams[p.name] = p.value; + } + } - properties[name] = { - type: schema.type || "string", - description: param.description || `${param.in} parameter: ${name}`, - ...extractSchemaDetails(schema), - }; + let baseUrl = + creds?.baseUrl || + spec.servers?.[0]?.url || + ""; + baseUrl = baseUrl.replace(/\/+$/, ""); - if (param.required) required.push(name); - } + let url = baseUrl + foundPath; - if (!Object.keys(properties).length) return null; + for (const p of pathParams) { + if (p.in === "path") { + const item = paramsForm.parameter.find((x: any) => x.name === p.name); + const val = item?.value; + if (!val) throw new Error(`Missing path param ${p.name}`); + url = url.replace(`{${p.name}}`, encodeURIComponent(val)); + } + } - return { type: "object", properties, required }; -} + const headers: Record = { + "Content-Type": "application/json", + }; -/** - * ============================ - * Extract RequestBody Schema - * ============================ - */ -function extractRequestBodySchema(operation: any): any | null { - return extractPreferredContentSchema(operation?.requestBody?.content); -} + if (creds?.token) headers["Authorization"] = `Bearer ${creds.token}`; + if (creds?.apiKey) headers["X-API-Key"] = creds.apiKey; -/** - * ============================ - * Create MCP Input Schema - * ============================ - */ -function createInputSchema(schema: any): any { - if (!schema || typeof schema !== "object") { - return { type: "object", properties: {}, additionalProperties: false }; - } + const axiosConfig: any = { + method: foundMethod, + url, + headers, + params: queryParams, + timeout: 30000, + }; - const properties: any = {}; - const required: string[] = Array.isArray(schema.required) ? [...schema.required] : []; + // === FIX: GET & HEAD tidak boleh punya body === + if (!(foundMethod === "get" || foundMethod === "head")) { + axiosConfig.data = Object.keys(bodyParams).length ? bodyParams : undefined; + } - if (schema.properties) { - for (const [key, prop] of Object.entries(schema.properties)) { - const cleaned = cleanProperty(prop); - if (cleaned) properties[key] = cleaned; - } - } + const resp = await axios(axiosConfig); + returnData.push({ json: resp.data }); - if (schema.type === "array" && schema.items) { - properties.items = cleanProperty(schema.items) || { type: "string" }; - } - - return { - type: "object", - properties, - required, - additionalProperties: false, - }; -} - -/** - * ============================ - * Clean Individual Schema Property - * ============================ - */ -function cleanProperty(prop: any): any | null { - if (!prop || typeof prop !== "object") return { type: "string" }; - - const out: any = { type: prop.type || "string" }; - - Object.assign(out, extractSchemaDetails(prop)); - - if (prop.properties) { - out.properties = {}; - for (const [k, v] of Object.entries(prop.properties)) { - const cleaned = cleanProperty(v); - if (cleaned) out.properties[k] = cleaned; + } catch (err: any) { + if (this.continueOnFail && this.continueOnFail()) { + returnData.push({ + json: { + error: true, + message: err.message, + response: err.response?.data, + }, + }); + continue; + } + throw err; + } } - if (Array.isArray(prop.required)) { - out.required = prop.required.filter((r: any) => typeof r === "string"); - } - } - - if (prop.items) { - out.items = cleanProperty(prop.items); - } - - if (Array.isArray(prop.oneOf)) out.oneOf = prop.oneOf.map(cleanProperty); - if (Array.isArray(prop.anyOf)) out.anyOf = prop.anyOf.map(cleanProperty); - if (Array.isArray(prop.allOf)) out.allOf = prop.allOf.map(cleanProperty); - - return out; -} - -/** - * ============================ - * Extract Allowed Schema Fields - * ============================ - */ -function extractSchemaDetails(schema: any) { - const allowed = [ - "description", - "examples", - "example", - "default", - "enum", - "pattern", - "minLength", - "maxLength", - "minimum", - "maximum", - "format", - "multipleOf", - "exclusiveMinimum", - "exclusiveMaximum", - ]; - - const out: any = {}; - for (const f of allowed) { - if (schema[f] !== undefined) out[f] = schema[f]; - } - return out; -} - -/** - * ============================ - * Clean tool name safely - * ============================ - */ -function cleanToolName(value: string): string { - if (!value) return "unnamed_tool"; - - return value - .replace(/[{}]/g, "") - .replace(/[^a-zA-Z0-9_]/g, "_") - .replace(/_+/g, "_") - .replace(/^_|_$/g, "") - .toLowerCase() || "unnamed_tool"; -} - -/** - * ============================ - * Public: getMcpTools - * ============================ - */ -export async function getMcpTools(url: string, filterTag: string): Promise { - try { - console.log(`Fetching OpenAPI spec: ${url}`); - - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - const json = await res.json(); - const tools = convertOpenApiToMcpTools(json, filterTag); - - console.log(`Generated ${tools.length} MCP tools`); - return tools; - } catch (err) { - console.error("Error fetching MCP Tools:", err); - throw err; + return [returnData]; } } diff --git a/src/package.json b/src/package.json index 4ba157e..c5812e4 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-openapi-node", - "version": "1.0.5", + "version": "1.0.6", "keywords": [ "n8n", "n8n-nodes"