Compare commits

...

7 Commits

Author SHA1 Message Date
bipproduction
4bf3129b3f build 2025-11-20 17:35:45 +08:00
bipproduction
4b83f843b2 build 2025-11-20 17:32:25 +08:00
bipproduction
8840266e70 build 2025-11-20 16:52:18 +08:00
bipproduction
d8fa7b12ca build 2025-11-20 16:47:37 +08:00
bipproduction
7466fb1151 build 2025-11-20 16:41:52 +08:00
bipproduction
6f1ec81cf3 build 2025-11-20 12:12:28 +08:00
bipproduction
8c6e370507 build 2025-11-20 11:03:42 +08:00
6 changed files with 1513 additions and 117 deletions

526
OpenapiMcpServer.ts.v3.txt Normal file
View File

@@ -0,0 +1,526 @@
// OpenapiMcpServer.ts
import {
INodeType,
INodeTypeDescription,
IWebhookFunctions,
IWebhookResponseData,
ILoadOptionsFunctions,
INodePropertyOptions,
} from 'n8n-workflow';
import { getMcpTools } from "../lib/mcp_tool_convert";
// ======================================================
// Cache tools per URL (with TTL & safe structure)
// ======================================================
type CachedTools = { timestamp: number; tools: any[] };
const TOOLS_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
const toolsCache = new Map<string, CachedTools>();
// ======================================================
// 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<any[]> {
const cacheKey = `${openapiUrl}::${filterTag || ""}`;
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;
}
}
// ======================================================
// 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
// - 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,
args: Record<string, any> = {},
baseUrl: string,
token?: string
) {
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
let path = x.path || `/${tool.name}`;
if (!baseUrl) {
throw new Error("Missing baseUrl in credentials");
}
const query: Record<string, any> = {};
const headers: Record<string, any> = {
// 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;
// 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;
// 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;
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":
// 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 "cookie":
cookies.push(`${name}=${value}`);
break;
case "body":
case "requestBody":
// prefer explicit body param; overwrite if multiple present
bodyPayload = value;
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 {
// fallback → semua args dianggap body
bodyPayload = args;
}
if (cookies.length > 0) {
headers["Cookie"] = cookies.join("; ");
}
// ======================================================
// Build Final URL
// ======================================================
// 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 & { headers: Record<string, any> } = { 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) {
// 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 resContentType = (res.headers.get("content-type") || "").toLowerCase();
const data = resContentType.includes("application/json")
? await res.json()
: await res.text();
return {
success: res.ok,
status: res.status,
method,
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,
tools: any[]
): Promise<JSONRPCResponse> {
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 {
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 makeError(-32601, `Tool '${toolName}' not found`) as JSONRPCResponse;
}
// Converter MCP content yang valid
function convertToMcpContent(data: any) {
// String → text
if (typeof data === "string") {
return {
type: "text",
text: data,
};
}
// Image (dengan __mcp_type)
if (data?.__mcp_type === "image" && data.base64) {
return {
type: "image",
data: data.base64,
mimeType: data.mimeType || "image/png",
};
}
// Audio
if (data?.__mcp_type === "audio" && data.base64) {
return {
type: "audio",
data: data.base64,
mimeType: data.mimeType || "audio/mpeg",
};
}
// Semua lainnya → text (untuk mencegah error Zod union)
return {
type: "text",
text: (() => {
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
})(),
};
}
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 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 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 = {
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<INodePropertyOptions[]> {
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: "" }];
}
// force refresh when user opens selector explicitly
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<IWebhookResponseData> {
const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string;
const filterTag = this.getNodeParameter("defaultFilter", 0) as string;
// 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 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: responses,
};
}
const single = await handleMCPRequest(
{ ...(body as JSONRPCRequest), credentials: creds },
tools
);
return {
webhookResponse: single,
};
}
}

451
mcp_tool_convert.ts.v2.txt Normal file
View File

@@ -0,0 +1,451 @@
// 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;
inputSchema: any;
"x-props": {
method: string;
path: string;
operationId?: string;
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;
}
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<any>(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 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);
if (tool) {
tools.push(tool);
}
} catch (error) {
console.error(`Error creating tool for ${method.toUpperCase()} ${path}:`, error);
continue;
}
}
}
return tools;
}
/**
* Create MCP tool from an OpenAPI operation.
* - Ensures x-props.parameters exists and describes path/query/header/cookie/requestBody
*/
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}`;
// 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" || method.toLowerCase() === "delete" || method.toLowerCase() === "head") {
schema = extractParametersSchema(operation.parameters || []);
} else {
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 {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
parameters, // executor will rely on this
},
inputSchema,
};
} catch (error) {
console.error(`Failed to create tool from operation:`, error);
return null;
}
}
/**
* Extract schema dari parameters (untuk GET/DELETE requests)
* - returns null if no parameters
*/
function extractParametersSchema(parameters: any[]): any | null {
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 allowed fields
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)
* - prefers application/json, handles form-data, urlencoded fallbacks
*/
function extractRequestBodySchema(operation: any): any | null {
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<any>(content)) {
if (value?.schema) {
return value.schema;
}
}
return null;
}
/**
* Buat input schema yang valid untuk MCP
* - preserves optional flags, required semantics, and nested properties
*/
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<any>(schema.properties)) {
if (!key || typeof key !== "string") continue;
try {
const cleanProp = cleanProperty(prop);
if (cleanProp) {
properties[key] = cleanProp;
// Check optional flag properly
const isOptional = prop?.optional === true || prop?.optional === "true";
const isInRequired = originalRequired.includes(key);
if (isInRequired && !isOptional) {
required.push(key);
}
}
} catch (error) {
console.error(`Error cleaning property ${key}:`, error);
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 {
type: "object",
properties,
required,
additionalProperties: false,
};
} catch (error) {
console.error("Error creating input schema:", error);
return defaultSchema;
}
}
/**
* 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") {
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, "")
// keep lowercase and stable
.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
* - preserves exported name getMcpTools
* - robust error handling and console diagnostics
*/
export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
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;
}
}

380
mcp_tool_convert.ts.v3.txt Normal file
View File

@@ -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<any>(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<any>(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<any>(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<McpTool[]> {
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;
}
}

View File

@@ -1,15 +1,5 @@
// 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;
@@ -21,24 +11,34 @@ 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)
* * @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") {
console.warn("Invalid OpenAPI JSON");
return tools;
}
const paths = openApiJson.paths || {};
// 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) {
console.warn("No paths found in OpenAPI spec");
return tools;
@@ -46,17 +46,27 @@ 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<any>(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 : [];
const lowerCaseTags = tags.map(t => typeof t === "string" ? t.toLowerCase() : "");
// 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())))) {
// ✅ 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;
}
@@ -76,8 +86,7 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M
}
/**
* Create MCP tool from an OpenAPI operation.
* - Ensures x-props.parameters exists and describes path/query/header/cookie/requestBody
* Buat MCP tool dari operation OpenAPI
*/
function createToolFromOperation(
path: string,
@@ -99,69 +108,16 @@ function createToolFromOperation(
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
// 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)
// ✅ Extract schema berdasarkan method
let schema;
if (method.toLowerCase() === "get" || method.toLowerCase() === "delete" || method.toLowerCase() === "head") {
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);
// if no requestBody but parameters exist, fall back to parameters schema
if (!schema) {
schema = extractParametersSchema(operation.parameters || []);
}
}
const inputSchema = createInputSchema(schema);
return {
@@ -174,7 +130,6 @@ function createToolFromOperation(
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
parameters, // executor will rely on this
},
inputSchema,
};
@@ -185,10 +140,9 @@ function createToolFromOperation(
}
/**
* Extract schema dari parameters (untuk GET/DELETE requests)
* - returns null if no parameters
* Extract schema dari parameters (untuk GET requests)
*/
function extractParametersSchema(parameters: any[]): any | null {
function extractParametersSchema(parameters: any[]): any {
if (!Array.isArray(parameters) || parameters.length === 0) {
return null;
}
@@ -199,7 +153,7 @@ function extractParametersSchema(parameters: any[]): any | null {
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;
@@ -209,7 +163,7 @@ function extractParametersSchema(parameters: any[]): any | null {
description: param.description || `${param.in} parameter: ${paramName}`,
};
// copy allowed fields
// ✅ 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) {
@@ -238,9 +192,8 @@ function extractParametersSchema(parameters: any[]): any | null {
/**
* Extract schema dari requestBody (untuk POST/PUT/etc requests)
* - prefers application/json, handles form-data, urlencoded fallbacks
*/
function extractRequestBodySchema(operation: any): any | null {
function extractRequestBodySchema(operation: any): any {
if (!operation.requestBody?.content) {
return null;
}
@@ -271,7 +224,6 @@ function extractRequestBodySchema(operation: any): any | null {
/**
* Buat input schema yang valid untuk MCP
* - preserves optional flags, required semantics, and nested properties
*/
function createInputSchema(schema: any): any {
const defaultSchema = {
@@ -298,10 +250,11 @@ function createInputSchema(schema: any): any {
if (cleanProp) {
properties[key] = cleanProp;
// Check optional flag properly
// ✅ 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);
}
@@ -311,9 +264,6 @@ 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 {
@@ -330,7 +280,6 @@ 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") {
@@ -373,7 +322,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");
}
@@ -414,7 +363,7 @@ function cleanToolName(name: string): string {
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
// keep lowercase and stable
// ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate)
.toLowerCase()
|| "unnamed_tool";
} catch (error) {
@@ -423,29 +372,33 @@ 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
* * @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<McpTool[]> {
export async function getMcpTools(url: string, filterTag: string | string[]): Promise<McpTool[]> {
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`);
const filterStr = _.castArray(filterTag).join(", ");
console.log(`✅ Successfully generated ${tools.length} MCP tools for tags: [${filterStr}]`);
return tools;
} catch (error) {
console.error("Error fetching MCP tools:", error);
throw error;
}
}
}

View File

@@ -7,6 +7,7 @@ import {
ILoadOptionsFunctions,
INodePropertyOptions,
} from 'n8n-workflow';
// Asumsi getMcpTools sekarang menerima string | string[]
import { getMcpTools } from "../lib/mcp_tool_convert";
// ======================================================
@@ -21,8 +22,10 @@ const toolsCache = new Map<string, CachedTools>();
// - 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<any[]> {
const cacheKey = `${openapiUrl}::${filterTag || ""}`;
async function loadTools(openapiUrl: string, filterTag: string | string[], forceRefresh = false): Promise<any[]> {
// 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<string[]> {
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<string>();
for (const methods of Object.values(paths)) {
if (typeof methods !== "object" || methods === null) continue;
for (const operation of Object.values<any>(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,
multipleValues: 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<INodePropertyOptions[]> {
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<INodePropertyOptions[]> {
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<IWebhookResponseData> {
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,
};
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-nodes-openapi-mcp-server",
"version": "1.1.28",
"version": "1.1.35",
"keywords": [
"n8n",
"n8n-nodes"