This commit is contained in:
bipproduction
2025-11-20 10:34:25 +08:00
parent db3561b6bf
commit 148ad40712
6 changed files with 1093 additions and 104 deletions

423
OpenapiMcpServer.ts.v2.txt Normal file
View File

@@ -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<string, any[]>();
// ======================================================
// Load OpenAPI → MCP Tools
// ======================================================
async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = false): Promise<any[]> {
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<string, any> = {},
baseUrl: string,
token?: string
) {
const x = tool["x-props"] || {};
const method = (x.method || "GET").toUpperCase();
let path = x.path || `/${tool.name}`;
const query: Record<string, any> = {};
const headers: Record<string, any> = {
"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<JSONRPCResponse> {
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<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: "" }];
}
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;
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,
};
}
}

380
mcp_tool_convert.ts.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

@@ -8,7 +8,9 @@
"version:update": "bun bin/version_update.ts", "version:update": "bun bin/version_update.ts",
"publish": "bun bin/version_update.ts && bun bin/build.ts && bun bin/publish.ts" "publish": "bun bin/version_update.ts && bun bin/build.ts && bun bin/publish.ts"
}, },
"dependencies": {}, "dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": { "devDependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"@types/bun": "latest", "@types/bun": "latest",
@@ -20,8 +22,7 @@
"n8n-workflow": "^1.116.0", "n8n-workflow": "^1.116.0",
"nock": "^14.0.10", "nock": "^14.0.10",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"dedent": "^1.7.0", "dedent": "^1.7.0"
"lodash": "^4.17.21"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"

View File

@@ -1,5 +1,15 @@
// mcp_tool_convert.ts
import _ from "lodash"; 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 { interface McpTool {
name: string; name: string;
description: string; description: string;
@@ -11,11 +21,13 @@ interface McpTool {
tag?: string; tag?: string;
deprecated?: boolean; deprecated?: boolean;
summary?: string; summary?: string;
parameters?: any[]; // added to communicate param locations to executor
}; };
} }
/** /**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions. * 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[] { export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): McpTool[] {
const tools: McpTool[] = []; const tools: McpTool[] = [];
@@ -34,20 +46,19 @@ export function convertOpenApiToMcpTools(openApiJson: any, filterTag: string): M
for (const [path, methods] of Object.entries(paths)) { for (const [path, methods] of Object.entries(paths)) {
if (!path || typeof path !== "string") continue; if (!path || typeof path !== "string") continue;
if (!methods || typeof methods !== "object") continue; if (!methods || typeof methods !== "object") continue;
for (const [method, operation] of Object.entries<any>(methods)) { for (const [method, operation] of Object.entries<any>(methods)) {
const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"]; const validMethods = ["get", "post", "put", "delete", "patch", "head", "options"];
if (!validMethods.includes(method.toLowerCase())) continue; if (!validMethods.includes(method.toLowerCase())) continue;
if (!operation || typeof operation !== "object") continue; if (!operation || typeof operation !== "object") continue;
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : []; const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
if (!tags.length || !tags.some(t => // If filterTag provided, require at least one tag to include it (case-insensitive)
typeof t === "string" && t.toLowerCase().includes(filterTag) if (filterTag && (!tags.length || !tags.some(t => typeof t === "string" && t.toLowerCase().includes(filterTag.toLowerCase())))) {
)) continue; continue;
}
try { try {
const tool = createToolFromOperation(path, method, operation, tags); 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( function createToolFromOperation(
path: string, path: string,
@@ -87,14 +99,67 @@ function createToolFromOperation(
operation.summary || operation.summary ||
`Execute ${method.toUpperCase()} ${path}`; `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; let schema;
if (method.toLowerCase() === "get") { if (method.toLowerCase() === "get" || method.toLowerCase() === "delete" || method.toLowerCase() === "head") {
// ✅ Untuk GET, ambil dari parameters (query/path)
schema = extractParametersSchema(operation.parameters || []); schema = extractParametersSchema(operation.parameters || []);
} else { } else {
// ✅ Untuk POST/PUT/etc, ambil dari requestBody
schema = extractRequestBodySchema(operation); schema = extractRequestBodySchema(operation);
// if no requestBody but parameters exist, fall back to parameters schema
if (!schema) {
schema = extractParametersSchema(operation.parameters || []);
}
} }
const inputSchema = createInputSchema(schema); const inputSchema = createInputSchema(schema);
@@ -109,6 +174,7 @@ function createToolFromOperation(
tag: tags[0], tag: tags[0],
deprecated: operation.deprecated || false, deprecated: operation.deprecated || false,
summary: operation.summary, summary: operation.summary,
parameters, // executor will rely on this
}, },
inputSchema, 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) { if (!Array.isArray(parameters) || parameters.length === 0) {
return null; return null;
} }
@@ -132,7 +199,7 @@ function extractParametersSchema(parameters: any[]): any {
for (const param of parameters) { for (const param of parameters) {
if (!param || typeof param !== "object") continue; 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)) { if (["path", "query", "header"].includes(param.in)) {
const paramName = param.name; const paramName = param.name;
if (!paramName || typeof paramName !== "string") continue; if (!paramName || typeof paramName !== "string") continue;
@@ -142,7 +209,7 @@ function extractParametersSchema(parameters: any[]): any {
description: param.description || `${param.in} parameter: ${paramName}`, description: param.description || `${param.in} parameter: ${paramName}`,
}; };
// ✅ Copy field tambahan dari schema // copy allowed fields
if (param.schema) { if (param.schema) {
const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"]; const allowedFields = ["examples", "example", "default", "enum", "pattern", "minLength", "maxLength", "minimum", "maximum", "format"];
for (const field of allowedFields) { for (const field of allowedFields) {
@@ -171,8 +238,9 @@ function extractParametersSchema(parameters: any[]): any {
/** /**
* Extract schema dari requestBody (untuk POST/PUT/etc requests) * 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) { if (!operation.requestBody?.content) {
return null; return null;
} }
@@ -203,6 +271,7 @@ function extractRequestBodySchema(operation: any): any {
/** /**
* Buat input schema yang valid untuk MCP * Buat input schema yang valid untuk MCP
* - preserves optional flags, required semantics, and nested properties
*/ */
function createInputSchema(schema: any): any { function createInputSchema(schema: any): any {
const defaultSchema = { const defaultSchema = {
@@ -229,11 +298,10 @@ function createInputSchema(schema: any): any {
if (cleanProp) { if (cleanProp) {
properties[key] = cleanProp; properties[key] = cleanProp;
// ✅ PERBAIKAN: Check optional flag dengan benar // Check optional flag properly
const isOptional = prop?.optional === true || prop?.optional === "true"; const isOptional = prop?.optional === true || prop?.optional === "true";
const isInRequired = originalRequired.includes(key); const isInRequired = originalRequired.includes(key);
// ✅ Hanya masukkan ke required jika memang required DAN bukan optional
if (isInRequired && !isOptional) { if (isInRequired && !isOptional) {
required.push(key); required.push(key);
} }
@@ -243,6 +311,9 @@ function createInputSchema(schema: any): any {
continue; 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 { return {
@@ -259,6 +330,7 @@ function createInputSchema(schema: any): any {
/** /**
* Bersihkan property dari field custom * Bersihkan property dari field custom
* - preserves nested structures, arrays, and combiners (oneOf/anyOf/allOf)
*/ */
function cleanProperty(prop: any): any | null { function cleanProperty(prop: any): any | null {
if (!prop || typeof prop !== "object") { if (!prop || typeof prop !== "object") {
@@ -342,7 +414,7 @@ function cleanToolName(name: string): string {
.replace(/[^a-zA-Z0-9_]/g, "_") .replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_") .replace(/_+/g, "_")
.replace(/^_|_$/g, "") .replace(/^_|_$/g, "")
// ❗️ METHOD PREFIX TIDAK DIHAPUS LAGI (agar tidak duplicate) // keep lowercase and stable
.toLowerCase() .toLowerCase()
|| "unnamed_tool"; || "unnamed_tool";
} catch (error) { } catch (error) {
@@ -351,13 +423,13 @@ function cleanToolName(name: string): string {
} }
} }
/** /**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP * 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[]> { export async function getMcpTools(url: string, filterTag: string): Promise<McpTool[]> {
try { try {
console.log(`Fetching OpenAPI spec from: ${url}`); console.log(`Fetching OpenAPI spec from: ${url}`);
const response = await fetch(url); const response = await fetch(url);
@@ -377,4 +449,3 @@ export async function getMcpTools(url: string, filterTag: string): Promise<McpTo
throw error; throw error;
} }
} }

View File

@@ -1,3 +1,4 @@
// OpenapiMcpServer.ts
import { import {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
@@ -9,18 +10,24 @@ import {
import { getMcpTools } from "../lib/mcp_tool_convert"; import { getMcpTools } from "../lib/mcp_tool_convert";
// ====================================================== // ======================================================
// Cache tools per URL // Cache tools per URL (with TTL & safe structure)
// ====================================================== // ======================================================
const toolsCache = new Map<string, any[]>(); 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 // 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[]> { async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = false): Promise<any[]> {
const cacheKey = `${openapiUrl}::${filterTag}`; const cacheKey = `${openapiUrl}::${filterTag || ""}`;
if (!forceRefresh && toolsCache.has(cacheKey)) { try {
return toolsCache.get(cacheKey)!; 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} ...`); console.log(`[MCP] 🔄 Refreshing tools from ${openapiUrl} ...`);
@@ -31,8 +38,18 @@ async function loadTools(openapiUrl: string, filterTag: string, forceRefresh = f
console.log(`[MCP] Tools: ${fetched.map((t: any) => t.name).join(", ")}`); console.log(`[MCP] Tools: ${fetched.map((t: any) => t.name).join(", ")}`);
} }
toolsCache.set(cacheKey, fetched); toolsCache.set(cacheKey, { timestamp: Date.now(), tools: fetched });
return 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;
}
} }
// ====================================================== // ======================================================
@@ -55,6 +72,9 @@ type JSONRPCResponse = {
// ====================================================== // ======================================================
// EXECUTE TOOL — SUPPORT PATH, QUERY, HEADER, BODY, COOKIE // 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( async function executeTool(
tool: any, tool: any,
@@ -66,29 +86,48 @@ async function executeTool(
const method = (x.method || "GET").toUpperCase(); const method = (x.method || "GET").toUpperCase();
let path = x.path || `/${tool.name}`; let path = x.path || `/${tool.name}`;
if (!baseUrl) {
throw new Error("Missing baseUrl in credentials");
}
const query: Record<string, any> = {}; const query: Record<string, any> = {};
const headers: Record<string, any> = { const headers: Record<string, any> = {
// default content-type; may be overridden by header params or request
"Content-Type": "application/json", "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
// Support multiple cookies: accumulate into array, then join
const cookies: string[] = [];
let bodyPayload: any = undefined; let bodyPayload: any = undefined;
// ====================================================== // x.parameters may have been produced by converter.
// Pisahkan args berdasarkan OpenAPI parameter location // Expected shape: [{ name, in, schema?, required? }]
// ======================================================
if (Array.isArray(x.parameters)) { if (Array.isArray(x.parameters)) {
for (const p of x.parameters) { for (const p of x.parameters) {
const name = p.name; 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; if (value === undefined) continue;
try {
switch (p.in) { switch (p.in) {
case "path": case "path":
path = path.replace(`{${name}}`, encodeURIComponent(value)); // 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; break;
case "query": case "query":
// handle array correctly: produce repeated keys for URLSearchParams
// Store as-is and handle later when building QS
query[name] = value; query[name] = value;
break; break;
@@ -97,43 +136,91 @@ async function executeTool(
break; break;
case "cookie": case "cookie":
headers["Cookie"] = `${name}=${value}`; cookies.push(`${name}=${value}`);
break; break;
case "body": case "body":
case "requestBody": case "requestBody":
// prefer explicit body param; overwrite if multiple present
bodyPayload = value; bodyPayload = value;
break; break;
default: default:
// unknown param location — put into body as fallback
bodyPayload = bodyPayload ?? {};
bodyPayload[name] = value;
break; break;
} }
} catch (err) {
console.warn(`[MCP] Skipping parameter ${name} due to error:`, err);
}
} }
} else { } else {
// fallback → semua args dianggap body // fallback → semua args dianggap body
bodyPayload = args; bodyPayload = args;
} }
if (cookies.length > 0) {
headers["Cookie"] = cookies.join("; ");
}
// ====================================================== // ======================================================
// Build Final URL // Build Final URL
// ====================================================== // ======================================================
let url = `${baseUrl}${path}`; // Ensure baseUrl doesn't end with duplicate slashes
const qs = new URLSearchParams(query).toString(); const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
if (qs) url += `?${qs}`; 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 // Build Request Options
// ====================================================== // ======================================================
const opts: RequestInit = { method, headers }; 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 (["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); opts.body = JSON.stringify(bodyPayload);
} }
}
console.log(`[MCP] → Calling ${method} ${url}`); console.log(`[MCP] → Calling ${method} ${url}`);
const res = await fetch(url, opts); const res = await fetch(url, opts);
const contentType = res.headers.get("content-type") || ""; const resContentType = (res.headers.get("content-type") || "").toLowerCase();
const data = contentType.includes("application/json")
const data = resContentType.includes("application/json")
? await res.json() ? await res.json()
: await res.text(); : await res.text();
@@ -144,11 +231,14 @@ async function executeTool(
url, url,
path, path,
data, data,
headers: res.headers, // keep for diagnostics
}; };
} }
// ====================================================== // ======================================================
// JSON-RPC Handler // JSON-RPC Handler
// - preserves handleMCPRequest name
// - improved error reporting, robust content conversion, batch safety
// ====================================================== // ======================================================
async function handleMCPRequest( async function handleMCPRequest(
request: JSONRPCRequest, request: JSONRPCRequest,
@@ -156,6 +246,13 @@ async function handleMCPRequest(
): Promise<JSONRPCResponse> { ): Promise<JSONRPCResponse> {
const { id, method, params, credentials } = request; 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) { switch (method) {
case "initialize": case "initialize":
return { return {
@@ -198,11 +295,7 @@ async function handleMCPRequest(
const tool = tools.find((t) => t.name === toolName); const tool = tools.find((t) => t.name === toolName);
if (!tool) { if (!tool) {
return { return makeError(-32601, `Tool '${toolName}' not found`) as JSONRPCResponse;
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool '${toolName}' not found` },
};
} }
// Converter MCP content yang valid // Converter MCP content yang valid
@@ -215,8 +308,8 @@ async function handleMCPRequest(
}; };
} }
// Jika kirim tipe khusus image // Jika kirim tipe khusus image (base64)
if (data?.__mcp_type === "image") { if (data?.__mcp_type === "image" && typeof data.base64 === "string") {
return { return {
type: "image", type: "image",
data: data.base64, data: data.base64,
@@ -225,7 +318,7 @@ async function handleMCPRequest(
} }
// Jika audio // Jika audio
if (data?.__mcp_type === "audio") { if (data?.__mcp_type === "audio" && typeof data.base64 === "string") {
return { return {
type: "audio", type: "audio",
data: data.base64, data: data.base64,
@@ -234,7 +327,7 @@ async function handleMCPRequest(
} }
// Jika resource link // Jika resource link
if (data?.__mcp_type === "resource_link") { if (data?.__mcp_type === "resource_link" && data.uri) {
return { return {
type: "resource_link", type: "resource_link",
name: data.name || "resource", 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") { if (typeof data === "object") {
const resource = Array.isArray(data) ? { items: data } : data;
return { return {
type: "resource", type: "resource",
resource: data, resource,
}; };
} }
@@ -278,29 +372,26 @@ async function handleMCPRequest(
}, },
}; };
} catch (err: any) { } catch (err: any) {
return { // return error with message and minimal debug info (avoid leaking secrets)
jsonrpc: "2.0", const debug = { message: err?.message, stack: err?.stack?.split("\n").slice(0, 5) };
id,
error: { code: -32603, message: err.message },
};
}
}
return makeError(-32603, err?.message || "Internal error", debug) as JSONRPCResponse;
}
}
case "ping": case "ping":
return { jsonrpc: "2.0", id, result: {} }; return { jsonrpc: "2.0", id, result: {} };
default: default:
return { return makeError(-32601, `Method '${method}' not found`) as JSONRPCResponse;
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method '${method}' not found` },
};
} }
} }
// ====================================================== // ======================================================
// MCP TRIGGER NODE // 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 { export class OpenapiMcpServer implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@@ -372,6 +463,7 @@ export class OpenapiMcpServer implements INodeType {
return [{ name: "❌ No OpenAPI URL provided", value: "" }]; return [{ name: "❌ No OpenAPI URL provided", value: "" }];
} }
// force refresh when user opens selector explicitly
const tools = await loadTools(openapiUrl, filterTag, true); const tools = await loadTools(openapiUrl, filterTag, true);
return [ return [
@@ -393,21 +485,43 @@ export class OpenapiMcpServer implements INodeType {
const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string; const openapiUrl = this.getNodeParameter("openapiUrl", 0) as string;
const filterTag = this.getNodeParameter("defaultFilter", 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 { const creds = await this.getCredentials("openapiMcpServerCredentials") as {
baseUrl: string; baseUrl: string;
token: string; token: string;
}; };
if (!creds || !creds.baseUrl) {
throw new Error("Missing openapiMcpServerCredentials or baseUrl");
}
const body = this.getBodyData(); const body = this.getBodyData();
// Batch handling: use Promise.allSettled and return array of results
if (Array.isArray(body)) { if (Array.isArray(body)) {
const responses = body.map((r) => const promises = body.map((r) =>
handleMCPRequest({ ...r, credentials: creds }, tools) 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 { return {
webhookResponse: await Promise.all(responses), jsonrpc: "2.0",
id: "error",
error: {
code: -32000,
message: "Unhandled handler error",
data: s.reason?.message ?? String(s.reason),
},
} as JSONRPCResponse;
});
return {
webhookResponse: responses,
}; };
} }

View File

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