This commit is contained in:
bipproduction
2025-12-06 19:39:33 +08:00
commit f07b60b310
24 changed files with 11196 additions and 0 deletions

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

142
.npmignore Normal file
View File

@@ -0,0 +1,142 @@
# Rules from: /Users/bip/tmp/test-mcp/.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

View File

@@ -0,0 +1,260 @@
// =====================
// MCP CLIENT TOOL (AI-Agent only)
// Compatible with OpenAPI-to-MCP servers
// =====================
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
/* -----------------------------
Types (MCP Spec Compliant)
----------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
}
interface MCPToolsListResult {
tools: MCPPayload[];
}
interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: any;
}
interface JsonRpcSuccessResponse<T = any> {
jsonrpc: '2.0';
id: string | number;
result: T;
}
interface JsonRpcErrorResponse {
jsonrpc: '2.0';
id: string | number;
error: { code: number; message: string; data?: any };
}
type JsonRpcResponse<T = any> = JsonRpcSuccessResponse<T> | JsonRpcErrorResponse;
/* -----------------------------
Helpers
----------------------------- */
function parseEndpoint(endpoint: string): string {
return endpoint.trim().replace(/\/+$/, '');
}
function generateRpcId(): string {
return `n8n_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/* -----------------------------
Node Definition
----------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Calls tools on an MCP-compliant server using the Model Context Protocol (JSON-RPC). ' +
'Use this node ONLY inside an AI-Agent; the agent will choose the tool automatically.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'MCP Server Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
description: 'The JSON-RPC endpoint of your MCP server',
required: true,
},
{
displayName: 'Tool Selection',
name: 'toolSelection',
type: 'options',
default: 'all',
options: [
{ name: 'All Tools', value: 'all' },
{ name: 'Selected Tools', value: 'selected' },
{ name: 'All Except Selected', value: 'except' },
],
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
default: [],
typeOptions: { loadOptionsMethod: 'getMcpTools' },
displayOptions: { show: { toolSelection: ['selected', 'except'] } },
},
],
};
methods = {
loadOptions: {
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
const id = generateRpcId();
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' };
let res: JsonRpcResponse<MCPToolsListResult>;
try {
res = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: request,
});
} catch (e) {
throw new NodeOperationError(this.getNode(), `MCP connect error: ${e}`);
}
if ('error' in res)
throw new NodeOperationError(
this.getNode(),
`MCP error: [${res.error.code}] ${res.error.message}`,
);
const tools = res.result?.tools;
if (!Array.isArray(tools))
throw new NodeOperationError(this.getNode(), 'Invalid tools list');
return tools.map((t) => ({
name: t.name,
value: t.name,
description: t.description,
}));
},
},
loadMethods: {
async getAiToolDefinitions(this: any) {
const ep = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
const id = generateRpcId();
const req: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' };
let res: JsonRpcResponse<MCPToolsListResult>;
try {
res = await this.helpers.request({
method: 'POST',
uri: ep,
json: true,
body: req,
});
} catch {
return [];
}
if ('error' in res || !Array.isArray(res.result?.tools)) return [];
const mode = this.getNodeParameter('toolSelection', 0) as string;
const selected = this.getNodeParameter('selectedTools', 0) as string[];
return res.result.tools
.filter((t) => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
})
.map((t) => ({
name: t.name,
description: t.description || '',
parameters:
t.inputSchema || {
type: 'object',
properties: {},
},
}));
},
},
};
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
for (let i = 0; i < items.length; i++) {
const json = items[i].json ?? {};
/* ==========================
Ambil tool dari payload AI-Agent
========================== */
let toolName: string | undefined;
let args: IDataObject = {};
toolName =
json.tool ||
json.toolName ||
json.tool_name ||
json.name ||
json.function;
if (json.arguments) args = json.arguments as IDataObject;
else if (json.params) args = json.params as IDataObject;
else if (json.parameters) args = json.parameters as IDataObject;
else if (json.input) args = json.input as IDataObject;
else if (toolName && Object.keys(json).length > 0) {
const { tool, toolName: tn, tool_name, name, function: fn, ...rest } =
json;
args = rest;
}
if (!toolName || typeof toolName !== 'string') {
throw new NodeOperationError(
this.getNode(),
'Node ini hanya boleh dipakai di dalam AI-Agent. ' +
'Pastikan Agent dipasang di workflow dan node ini terdaftar sebagai tool.',
);
}
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id: generateRpcId(),
method: 'tools/call',
params: { name: toolName, arguments: args },
};
try {
const res: JsonRpcResponse = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: req,
});
if ('error' in res) {
output.push({
json: { error: true, tool: toolName, ...res.error },
pairedItem: { item: i },
});
} else {
output.push({
json: { error: false, tool: toolName, result: res.result },
pairedItem: { item: i },
});
}
} catch (e: any) {
output.push({
json: { error: true, tool: toolName, message: e.message || 'Unknown' },
pairedItem: { item: i },
});
}
}
return [output];
}
}

View File

@@ -0,0 +1,361 @@
// =====================
// MCP CLIENT TOOL (AI-Agent only)
// Compatible with OpenAPI-to-MCP servers
// =====================
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
/* -----------------------------
Types (MCP Spec Compliant)
----------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
}
interface MCPToolsListResult {
tools: MCPPayload[];
}
interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: any;
}
interface JsonRpcSuccessResponse<T = any> {
jsonrpc: '2.0';
id: string | number;
result: T;
}
interface JsonRpcErrorResponse {
jsonrpc: '2.0';
id: string | number;
error: { code: number; message: string; data?: any };
}
type JsonRpcResponse<T = any> = JsonRpcSuccessResponse<T> | JsonRpcErrorResponse;
/* -----------------------------
Helpers
----------------------------- */
function parseEndpoint(endpoint: string): string {
return endpoint.trim().replace(/\/+$/, '');
}
function generateRpcId(): string {
return `n8n_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/* -----------------------------
Node Definition
----------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Connects to MCP server and exposes available tools to AI Agent. ' +
'The agent will automatically discover and call tools from the MCP server. ' +
'This node acts as a bridge between n8n AI Agent and MCP protocol.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'MCP Server Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
description: 'The JSON-RPC endpoint of your MCP server',
required: true,
},
{
displayName: 'Tool Selection Mode',
name: 'toolSelection',
type: 'options',
default: 'all',
options: [
{ name: 'Expose All Tools', value: 'all', description: 'Agent can use all tools from MCP server' },
{ name: 'Only Selected Tools', value: 'selected', description: 'Agent can only use selected tools' },
{ name: 'All Except Selected', value: 'except', description: 'Agent can use all tools except selected ones' },
],
description: 'Control which MCP tools are available to the AI Agent',
},
{
displayName: 'Tools',
name: 'selectedTools',
type: 'multiOptions',
default: [],
typeOptions: { loadOptionsMethod: 'getMcpTools' },
displayOptions: { show: { toolSelection: ['selected', 'except'] } },
description: 'Select tools to include or exclude',
},
],
};
methods = {
loadOptions: {
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
const id = generateRpcId();
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' };
let res: JsonRpcResponse<MCPToolsListResult>;
try {
res = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: request
});
} catch (e: any) {
throw new NodeOperationError(
this.getNode(),
`Failed to connect to MCP server: ${e.message || e}`
);
}
if ('error' in res) {
throw new NodeOperationError(
this.getNode(),
`MCP server error: [${res.error.code}] ${res.error.message}`
);
}
const tools = res.result?.tools;
if (!Array.isArray(tools)) {
throw new NodeOperationError(
this.getNode(),
'Invalid response from MCP server: tools list not found'
);
}
return tools.map(t => ({
name: `${t.name}${t.description ? ` - ${t.description}` : ''}`,
value: t.name
}));
},
},
loadMethods: {
// Method ini dipanggil oleh AI Agent untuk mendapatkan list tool yang tersedia
async getAiToolDefinitions(this: any) {
const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
const id = generateRpcId();
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' };
let res: JsonRpcResponse<MCPToolsListResult>;
try {
res = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: request
});
} catch (e) {
console.error('Failed to fetch MCP tools:', e);
return [];
}
if ('error' in res || !Array.isArray(res.result?.tools)) {
console.error('Invalid MCP response:', res);
return [];
}
const mode = this.getNodeParameter('toolSelection', 0) as string;
const selected = this.getNodeParameter('selectedTools', 0, []) as string[];
// Filter tools berdasarkan mode selection
const filteredTools = res.result.tools.filter(t => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
// Format tools untuk AI Agent (OpenAI function calling format)
return filteredTools.map(t => ({
name: t.name,
description: t.description || `Call ${t.name} tool on MCP server`,
parameters: t.inputSchema || {
type: 'object',
properties: {},
additionalProperties: true
},
}));
},
},
};
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = parseEndpoint(this.getNodeParameter('endpoint', 0) as string);
for (let i = 0; i < items.length; i++) {
const json = items[i].json ?? {};
/* =============================================
Extract tool name & arguments from AI Agent payload
============================================= */
let toolName: string | undefined;
let args: IDataObject = {};
// Coba berbagai format yang mungkin dikirim oleh agent
toolName = (json.tool || json.toolName || json.tool_name || json.name || json.function) as string;
if (json.arguments) {
args = json.arguments as IDataObject;
} else if (json.params) {
args = json.params as IDataObject;
} else if (json.parameters) {
args = json.parameters as IDataObject;
} else if (json.input) {
args = json.input as IDataObject;
} else if (toolName) {
// Ambil semua field kecuali yang digunakan untuk tool name
const { tool, toolName: tn, tool_name, name, function: fn, ...rest } = json;
args = rest;
}
// Jika tidak ada toolName, berarti dipanggil manual (bukan dari agent)
// Kembalikan info tentang available tools
if (!toolName || typeof toolName !== 'string') {
try {
// Fetch available tools dari MCP server
const id = generateRpcId();
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method: 'tools/list' };
const res: JsonRpcResponse<MCPToolsListResult> = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: request,
});
if ('error' in res) {
output.push({
json: {
message: 'This node is designed to work with AI Agent',
note: 'When used with AI Agent, the agent will automatically select and call tools',
mcpServerError: res.error,
},
pairedItem: { item: i },
});
} else {
const mode = this.getNodeParameter('toolSelection', 0) as string;
const selected = this.getNodeParameter('selectedTools', 0, []) as string[];
const allTools = res.result?.tools || [];
const availableTools = allTools.filter(t => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
output.push({
json: {
message: 'This node is designed to work with AI Agent',
note: 'When used with AI Agent, the agent will automatically select and call tools',
mcpServer: endpoint,
totalToolsOnServer: allTools.length,
exposedToAgent: availableTools.length,
availableTools: availableTools.map(t => ({
name: t.name,
description: t.description,
})),
},
pairedItem: { item: i },
});
}
} catch (e: any) {
output.push({
json: {
message: 'This node is designed to work with AI Agent',
note: 'When used with AI Agent, the agent will automatically select and call tools',
mcpServer: endpoint,
connectionError: e.message || 'Failed to connect to MCP server',
},
pairedItem: { item: i },
});
}
continue;
}
// Build JSON-RPC request untuk MCP server
const rpcRequest: JsonRpcRequest = {
jsonrpc: '2.0',
id: generateRpcId(),
method: 'tools/call',
params: {
name: toolName,
arguments: args
},
};
try {
const rpcResponse: JsonRpcResponse = await this.helpers.request({
method: 'POST',
uri: endpoint,
json: true,
body: rpcRequest,
});
if ('error' in rpcResponse) {
// MCP server return error
output.push({
json: {
success: false,
tool: toolName,
arguments: args,
error: {
code: rpcResponse.error.code,
message: rpcResponse.error.message,
data: rpcResponse.error.data,
}
},
pairedItem: { item: i }
});
} else {
// Success
output.push({
json: {
success: true,
tool: toolName,
arguments: args,
result: rpcResponse.result
},
pairedItem: { item: i }
});
}
} catch (e: any) {
// Network or other error
output.push({
json: {
success: false,
tool: toolName,
arguments: args,
error: {
message: e.message || 'Unknown error',
details: e.toString(),
}
},
pairedItem: { item: i }
});
}
}
return [output];
}
}

View File

@@ -0,0 +1,377 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request';
/* -------------------------
Types
------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
'x-props': {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
interface MCPToolsResponse {
tools: MCPPayload[];
}
/* -------------------------
Helper Functions
------------------------- */
/**
* Extract base URL and MCP path from endpoint
* Examples:
* - "https://example.com/mcp" → { baseUrl: "https://example.com", mcpPath: "/mcp" }
* - "https://example.com" → { baseUrl: "https://example.com", mcpPath: "" }
*/
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
const trimmed = endpoint.trim().replace(/\/+$/, '');
// Check if endpoint has /mcp or similar path
const url = new URL(trimmed);
const pathname = url.pathname;
if (pathname && pathname !== '/') {
// Has path component (e.g., /mcp)
return {
baseUrl: `${url.protocol}//${url.host}`,
mcpPath: pathname
};
}
// No path component
return {
baseUrl: trimmed,
mcpPath: ''
};
}
/* -------------------------
Node Implementation
------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
required: true,
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
},
{
displayName: 'Server Transport',
name: 'transport',
type: 'options',
options: [
{ name: 'HTTP (Streamable)', value: 'http' },
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
],
default: 'http',
},
{
displayName: 'Tools to Include',
name: 'toolSelection',
type: 'options',
options: [
{ name: 'All', value: 'all' },
{ name: 'Selected', value: 'selected' },
{ name: 'All Except', value: 'except' },
],
default: 'all',
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getMcpTools',
},
default: [],
displayOptions: {
show: {
toolSelection: ['selected', 'except'],
},
},
},
],
};
/* ----------------------------------------
loadOptions + loadMethods
---------------------------------------- */
methods = {
loadOptions: {
/** Fetch tools for UI multi-select */
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader: any = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
const requestOptions: OptionsWithUri = {
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
};
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request(requestOptions);
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
}
return toolsRes.tools.map((tool) => ({
name: `${tool.name} — ${tool.description || tool['x-props'].summary || ''}`,
value: tool.name,
}));
},
},
/* ----------------------------------------
LLM / Agent function definitions
---------------------------------------- */
loadMethods: {
async getAiToolDefinitions(this: any) {
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
if (!endpoint) return [];
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials?.('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch {
return [];
}
if (!Array.isArray(toolsRes?.tools)) return [];
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
const filtered = toolsRes.tools.filter((t) => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
return filtered.map((t) => ({
name: t.name,
description: t.description || t['x-props'].summary || '',
parameters:
t.inputSchema && typeof t.inputSchema === 'object'
? t.inputSchema
: { type: 'object', properties: {} },
__meta: { ...t['x-props'] },
}));
},
},
};
/* ----------------------------------------
EXECUTION: Run tool from AI input
---------------------------------------- */
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
/* Fetch tools once to build map */
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await this.helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
}
const toolMap = new Map<string, MCPPayload>();
for (const t of toolsRes.tools) toolMap.set(t.name, t);
/* ----------------------------------------
Execute each item
---------------------------------------- */
for (const item of items) {
try {
const json = item.json || {};
// Accept multiple naming styles
const toolName =
json.tool ||
json.toolName ||
json.tool_name ||
null;
if (!toolName) {
throw new NodeOperationError(
this.getNode(),
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
);
}
const tool = toolMap.get(toolName);
if (!tool) {
throw new NodeOperationError(
this.getNode(),
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
}
let args: IDataObject =
json.arguments ||
json.args ||
json.parameters ||
{};
/* Normalize HTTP info */
const method = (tool['x-props'].method || 'GET').toUpperCase();
let path = tool['x-props'].path || '/';
if (!path.startsWith('/')) path = `/${path}`;
// ✅ FIXED: Use only baseUrl for tool execution (without MCP path)
// This ensures we hit the actual API endpoints, not the MCP metadata endpoints
const url = `${baseUrl}${path}`;
const req: OptionsWithUri = {
method,
uri: url,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 20000,
};
// GET/DELETE → querystring
if (method === 'GET' || method === 'DELETE') {
req.qs = args;
} else {
req.body = args;
}
let response: any;
try {
response = await this.helpers.request(req);
} catch (err: any) {
throw new NodeOperationError(
this.getNode(),
`Error calling tool "${toolName}" at ${url}: ${err.message || err}`,
);
}
/* Response */
output.push({
json: {
error: false,
tool: toolName,
method,
path,
url, // Added for debugging
response,
},
});
} catch (error) {
output.push({
json: {
error: true,
message: (error as Error).message,
input: item.json,
},
});
}
}
return [output];
}
}

View File

@@ -0,0 +1,377 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request';
/* -------------------------
Types
------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
'x-props': {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
interface MCPToolsResponse {
tools: MCPPayload[];
}
/* -------------------------
Helper Functions
------------------------- */
/**
* Extract base URL and MCP path from endpoint
* Examples:
* - "https://example.com/mcp" → { baseUrl: "https://example.com", mcpPath: "/mcp" }
* - "https://example.com" → { baseUrl: "https://example.com", mcpPath: "" }
*/
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
const trimmed = endpoint.trim().replace(/\/+$/, '');
// Check if endpoint has /mcp or similar path
const url = new URL(trimmed);
const pathname = url.pathname;
if (pathname && pathname !== '/') {
// Has path component (e.g., /mcp)
return {
baseUrl: `${url.protocol}//${url.host}`,
mcpPath: pathname
};
}
// No path component
return {
baseUrl: trimmed,
mcpPath: ''
};
}
/* -------------------------
Node Implementation
------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
required: true,
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
},
{
displayName: 'Server Transport',
name: 'transport',
type: 'options',
options: [
{ name: 'HTTP (Streamable)', value: 'http' },
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
],
default: 'http',
},
{
displayName: 'Tools to Include',
name: 'toolSelection',
type: 'options',
options: [
{ name: 'All', value: 'all' },
{ name: 'Selected', value: 'selected' },
{ name: 'All Except', value: 'except' },
],
default: 'all',
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getMcpTools',
},
default: [],
displayOptions: {
show: {
toolSelection: ['selected', 'except'],
},
},
},
],
};
/* ----------------------------------------
loadOptions + loadMethods
---------------------------------------- */
methods = {
loadOptions: {
/** Fetch tools for UI multi-select */
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader: any = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
const requestOptions: OptionsWithUri = {
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
};
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request(requestOptions);
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
}
return toolsRes.tools.map((tool) => ({
name: `${tool.name} — ${tool.description || tool['x-props'].summary || ''}`,
value: tool.name,
}));
},
},
/* ----------------------------------------
LLM / Agent function definitions
---------------------------------------- */
loadMethods: {
async getAiToolDefinitions(this: any) {
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
if (!endpoint) return [];
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials?.('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch {
return [];
}
if (!Array.isArray(toolsRes?.tools)) return [];
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
const filtered = toolsRes.tools.filter((t) => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
return filtered.map((t) => ({
name: t.name,
description: t.description || t['x-props'].summary || '',
parameters:
t.inputSchema && typeof t.inputSchema === 'object'
? t.inputSchema
: { type: 'object', properties: {} },
__meta: { ...t['x-props'] },
}));
},
},
};
/* ----------------------------------------
EXECUTION: Run tool from AI input
---------------------------------------- */
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
/* Fetch tools once to build map */
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await this.helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
}
const toolMap = new Map<string, MCPPayload>();
for (const t of toolsRes.tools) toolMap.set(t.name, t);
/* ----------------------------------------
Execute each item
---------------------------------------- */
for (const item of items) {
try {
const json = item.json || {};
// Accept multiple naming styles
const toolName =
json.tool ||
json.toolName ||
json.tool_name ||
null;
if (!toolName) {
throw new NodeOperationError(
this.getNode(),
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
);
}
const tool = toolMap.get(toolName);
if (!tool) {
throw new NodeOperationError(
this.getNode(),
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
}
let args: IDataObject =
json.arguments ||
json.args ||
json.parameters ||
{};
/* Normalize HTTP info */
const method = (tool['x-props'].method || 'GET').toUpperCase();
let path = tool['x-props'].path || '/';
if (!path.startsWith('/')) path = `/${path}`;
// ✅ FIXED: Use only baseUrl for tool execution (without MCP path)
// This ensures we hit the actual API endpoints, not the MCP metadata endpoints
const url = `${baseUrl}${path}`;
const req: OptionsWithUri = {
method,
uri: url,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 20000,
};
// GET/DELETE → querystring
if (method === 'GET' || method === 'DELETE') {
req.qs = args;
} else {
req.body = args;
}
let response: any;
try {
response = await this.helpers.request(req);
} catch (err: any) {
throw new NodeOperationError(
this.getNode(),
`Error calling tool "${toolName}" at ${url}: ${err.message || err}`,
);
}
/* Response */
output.push({
json: {
error: false,
tool: toolName,
method,
path,
url, // Added for debugging
response,
},
});
} catch (error) {
output.push({
json: {
error: true,
message: (error as Error).message,
input: item.json,
},
});
}
}
return [output];
}
}

141
McpClientTool.node.txt Normal file
View File

@@ -0,0 +1,141 @@
import {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
NodeOperationError,
} from "n8n-workflow";
import axios from "axios";
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: "MCP Client Tool",
name: "mcpClientTool",
group: ["transform"],
version: 1,
description: "Client untuk berinteraksi dengan MCP Server",
defaults: {
name: "MCP Client Tool",
},
icon: "file:icon.svg",
inputs: ["main"],
outputs: ["main"],
credentials: [
{
name: "mcptool",
required: false,
},
],
usableAsTool: true,
properties: [
{
displayName: "MCP Endpoint",
name: "endpoint",
type: "string",
default: "",
placeholder: "https://your-mcp-server.com/mcp",
required: true,
},
{
displayName: "Transport",
name: "transport",
type: "options",
options: [
{ name: "HTTP", value: "http" },
{ name: "HTTP Streamable", value: "http-stream" },
],
default: "http-stream",
},
{
displayName: "Authentication",
name: "authMode",
type: "options",
default: "none",
options: [
{ name: "None", value: "none" },
{ name: "API Key", value: "apikey" },
],
},
{
displayName: "API Key",
name: "apiKey",
type: "string",
default: "",
typeOptions: { password: true },
displayOptions: {
show: { authMode: ["apikey"] },
},
},
{
displayName: "Payload JSON",
name: "payload",
type: "json",
default: `{ "action": "ping" }`,
required: true,
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const endpoint = this.getNodeParameter("endpoint", 0) as string;
const transport = this.getNodeParameter("transport", 0) as string;
const authMode = this.getNodeParameter("authMode", 0) as string;
const apiKey = this.getNodeParameter("apiKey", 0) as string;
const payload = this.getNodeParameter("payload", 0) as object;
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authMode === "apikey") {
headers["Authorization"] = `Bearer ${apiKey}`;
}
// MCP Standard Payload Envelope
const mcpBody = {
transport,
timestamp: Date.now(),
payload,
};
const response = await axios.post(endpoint, mcpBody, {
headers,
responseType: transport === "http-stream" ? "stream" : "json",
});
// If streaming mode (MCP Streamable)
if (transport === "http-stream" && response.data.readable) {
const chunks: Buffer[] = [];
for await (const chunk of response.data) {
chunks.push(Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
return [this.helpers.returnJsonArray(json)];
}
// Normal JSON response
return [this.helpers.returnJsonArray(response.data)];
} catch (error: any) {
throw new NodeOperationError(this.getNode(), error, {
message: "Failed to connect to MCP Server",
});
}
}
}

377
McpClientTool.node.v1.txt Normal file
View File

@@ -0,0 +1,377 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request';
/* -------------------------
Types
------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
'x-props': {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
interface MCPToolsResponse {
tools: MCPPayload[];
}
/* -------------------------
Helper Functions
------------------------- */
/**
* Extract base URL and MCP path from endpoint
* Examples:
* - "https://example.com/mcp" → { baseUrl: "https://example.com", mcpPath: "/mcp" }
* - "https://example.com" → { baseUrl: "https://example.com", mcpPath: "" }
*/
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
const trimmed = endpoint.trim().replace(/\/+$/, '');
// Check if endpoint has /mcp or similar path
const url = new URL(trimmed);
const pathname = url.pathname;
if (pathname && pathname !== '/') {
// Has path component (e.g., /mcp)
return {
baseUrl: `${url.protocol}//${url.host}`,
mcpPath: pathname
};
}
// No path component
return {
baseUrl: trimmed,
mcpPath: ''
};
}
/* -------------------------
Node Implementation
------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
default: 'https://cld-dkr-prod-jenna-mcp.wibudev.com/mcp',
required: true,
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
},
{
displayName: 'Server Transport',
name: 'transport',
type: 'options',
options: [
{ name: 'HTTP (Streamable)', value: 'http' },
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
],
default: 'http',
},
{
displayName: 'Tools to Include',
name: 'toolSelection',
type: 'options',
options: [
{ name: 'All', value: 'all' },
{ name: 'Selected', value: 'selected' },
{ name: 'All Except', value: 'except' },
],
default: 'all',
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getMcpTools',
},
default: [],
displayOptions: {
show: {
toolSelection: ['selected', 'except'],
},
},
},
],
};
/* ----------------------------------------
loadOptions + loadMethods
---------------------------------------- */
methods = {
loadOptions: {
/** Fetch tools for UI multi-select */
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader: any = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
const requestOptions: OptionsWithUri = {
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
};
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request(requestOptions);
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
}
return toolsRes.tools.map((tool) => ({
name: `${tool.name} — ${tool.description || tool['x-props'].summary || ''}`,
value: tool.name,
}));
},
},
/* ----------------------------------------
LLM / Agent function definitions
---------------------------------------- */
loadMethods: {
async getAiToolDefinitions(this: any) {
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
if (!endpoint) return [];
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials?.('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch {
return [];
}
if (!Array.isArray(toolsRes?.tools)) return [];
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
const filtered = toolsRes.tools.filter((t) => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
return filtered.map((t) => ({
name: t.name,
description: t.description || t['x-props'].summary || '',
parameters:
t.inputSchema && typeof t.inputSchema === 'object'
? t.inputSchema
: { type: 'object', properties: {} },
__meta: { ...t['x-props'] },
}));
},
},
};
/* ----------------------------------------
EXECUTION: Run tool from AI input
---------------------------------------- */
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
/* Fetch tools once to build map */
// Use full endpoint (with MCP path) for /tools
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await this.helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
}
const toolMap = new Map<string, MCPPayload>();
for (const t of toolsRes.tools) toolMap.set(t.name, t);
/* ----------------------------------------
Execute each item
---------------------------------------- */
for (const item of items) {
try {
const json = item.json || {};
// Accept multiple naming styles
const toolName =
json.tool ||
json.toolName ||
json.tool_name ||
null;
if (!toolName) {
throw new NodeOperationError(
this.getNode(),
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
);
}
const tool = toolMap.get(toolName);
if (!tool) {
throw new NodeOperationError(
this.getNode(),
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
}
let args: IDataObject =
json.arguments ||
json.args ||
json.parameters ||
{};
/* Normalize HTTP info */
const method = (tool['x-props'].method || 'GET').toUpperCase();
let path = tool['x-props'].path || '/';
if (!path.startsWith('/')) path = `/${path}`;
// ✅ FIXED: Use only baseUrl for tool execution (without MCP path)
// This ensures we hit the actual API endpoints, not the MCP metadata endpoints
const url = `${baseUrl}${path}`;
const req: OptionsWithUri = {
method,
uri: url,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 20000,
};
// GET/DELETE → querystring
if (method === 'GET' || method === 'DELETE') {
req.qs = args;
} else {
req.body = args;
}
let response: any;
try {
response = await this.helpers.request(req);
} catch (err: any) {
throw new NodeOperationError(
this.getNode(),
`Error calling tool "${toolName}" at ${url}: ${err.message || err}`,
);
}
/* Response */
output.push({
json: {
error: false,
tool: toolName,
method,
path,
url, // Added for debugging
response,
},
});
} catch (error) {
output.push({
json: {
error: true,
message: (error as Error).message,
input: item.json,
},
});
}
}
return [output];
}
}

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
# n8n Node: Wajs
Ini adalah *community node* n8n untuk berinteraksi dengan layanan Wajs.
[![n8n-compatible](https://img.shields.io/badge/n8n-compatible-success.svg)](https://n8n.io)
[![npm version](https://img.shields.io/npm/v/n8n-nodes-wajs.svg)](https://www.npmjs.com/package/n8n-nodes-wajs)
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://spdx.org/licenses/MIT.html)
## Kompatibilitas n8n
Telah diuji dengan n8n versi `1.117.1` dan yang lebih baru.
## Instalasi
1. Buka n8n.
2. Pergi ke **Settings > Community Nodes**.
3. Pilih **Install**.
4. Masukkan `n8n-nodes-wajs` sebagai nama paket npm.
5. Klik **Install**.
Setelah instalasi selesai, node akan muncul di panel node Anda.
## Kredensial
Node ini memerlukan kredensial untuk terhubung ke Wajs.
1. Dari sidebar n8n, buka **Credentials > New**.
2. Cari **Wajs Credentials** dan pilih.
3. Isi informasi yang diperlukan untuk mengautentikasi akun Wajs Anda.
## Operasi
Node ini menyediakan operasi berikut:
* **Wajs**: Operasi utama untuk berinteraksi dengan API Wajs.
## Pengembangan (Development)
Berikut adalah cara untuk melakukan pengembangan pada node ini secara lokal.
### Prasyarat
- [Bun](https://bun.sh/) terinstal di sistem Anda.
- Lingkungan n8n untuk pengujian.
### Setup
1. Clone repositori ini.
2. Jalankan `bun install` untuk menginstal semua dependensi.
### Skrip yang Tersedia
- `bun run init`: Menginisialisasi proyek.
- `bun run build`: Mem-build node dari source code TypeScript.
- `bun run generate`: Men-generate file definisi node.
- `bun run version:update`: Memperbarui versi patch pada `package.json`.
- `bun run publish`: Menjalankan proses build, update versi, dan publikasi (memerlukan konfigurasi lebih lanjut).
## Lisensi
Dilisensikan di bawah [Lisensi MIT](LICENSE.md).

87
bin/build.ts Executable file
View File

@@ -0,0 +1,87 @@
import { execSync } from "node:child_process";
import { readdir, rm, mkdir, cp } from "node:fs/promises";
import { join, relative, dirname } from "node:path";
const SRC = "src";
const DIST = "dist";
const ASSET_EXT = [
".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif",
".json", ".html", ".css", ".txt", ".ico", ".md"
];
// Recursively scan directory tree
async function walk(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walk(full));
} else {
files.push(full);
}
}
return files;
}
async function build() {
console.log("🧹 Cleaning dist/...");
await rm(DIST, { recursive: true, force: true });
await mkdir(DIST, { recursive: true });
console.log("🔍 Scanning src/...");
const allFiles = await walk(SRC);
const tsFiles = allFiles.filter(f =>
f.endsWith(".ts") || f.endsWith(".tsx") || f.endsWith(".js")
);
const assets = allFiles.filter(f =>
ASSET_EXT.some(ext => f.toLowerCase().endsWith(ext))
);
console.log("⚡ Building & Minifying TypeScript...");
for (const file of tsFiles) {
const rel = relative(SRC, file);
const outDir = join(DIST, dirname(rel));
await mkdir(outDir, { recursive: true });
await Bun.build({
entrypoints: [file],
outdir: outDir,
splitting: false,
minify: true, // ← minify otomatis
sourcemap: "external",
target: "browser"
});
console.log(" ✔ Built:", rel);
}
console.log("📁 Copying assets...");
for (const file of assets) {
const rel = relative(SRC, file);
const dest = join(DIST, rel);
await mkdir(dirname(dest), { recursive: true });
await cp(file, dest);
console.log(" ✔ Copied:", rel);
}
console.log("🎉 Build complete!");
}
const version = execSync('npm view lodash version').toString().trim();
console.log("🚀 Version:", version);
execSync("cd src && npm version ", { stdio: 'inherit' })
build()

408
bin/generate.ts Executable file
View File

@@ -0,0 +1,408 @@
import fs from 'fs/promises';
import path from 'path';
import _ from 'lodash';
import { execSync } from 'child_process';
const NAMESPACE = process.env.NAMESPACE
const OPENAPI_URL = process.env.OPENAPI_URL
if (!NAMESPACE || !OPENAPI_URL) {
throw new Error('NAMESPACE and OPENAPI_URL are required')
}
const namespaceCase = _.startCase(_.camelCase(NAMESPACE)).replace(/ /g, '');
const OUT_DIR = path.join('src', 'nodes');
const OUT_FILE = path.join(OUT_DIR, `${namespaceCase}.node.ts`);
const CREDENTIAL_NAME = _.camelCase(NAMESPACE);
interface OpenAPI {
paths: Record<string, any>;
components?: any;
tags?: { name: string }[];
}
// helpers
const safe = (s: string) => s.replace(/[^a-zA-Z0-9]/g, '_');
// load OpenAPI
async function loadOpenAPI(): Promise<OpenAPI> {
const url = OPENAPI_URL!
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch OpenAPI: ${res.status} ${res.statusText}`);
return res.json() as Promise<OpenAPI>;
}
// convert operation to value
function operationValue(tag: string, operationId: string) {
return _.snakeCase(`${tag}_${operationId}`);
}
// build properties for dropdown + dynamic inputs
function buildPropertiesBlock(ops: Array<any>) {
const options = ops.map((op) => {
const value = operationValue(op.tag, op.operationId);
const label = `${op.tag} ${_.kebabCase(op.operationId).replace(/-/g, ' ')}`;
return `{ name: '${label}', value: '${value}', description: ${JSON.stringify(
op.summary || op.description || '',
)}, action: '${label}' }`;
});
const dropdown = `
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
${options.join(',\n ')}
],
default: '${operationValue(ops[0].tag, ops[0].operationId).replace(/_/g, ' ')}',
description: 'Pilih endpoint yang akan dipanggil'
}
`;
const dynamicProps: string[] = [];
for (const op of ops) {
const value = operationValue(op.tag, op.operationId);
// Query fields
for (const name of op.query ?? []) {
dynamicProps.push(`
{
displayName: 'Query ${name}',
name: 'query_${name}',
type: 'string',
default: '',
placeholder: '${name}',
description: '${name}',
displayOptions: { show: { operation: ['${value}'] , "@tool": [true]} }
}`);
}
// Body fields (required only)
const bodyRequired = op.body?.required ?? [];
const bodySchema = op.body?.schema ?? {};
for (const name of bodyRequired) {
const schema = bodySchema[name] ?? {};
let type = 'string';
if (schema.type === 'number' || schema.type === 'integer') type = 'number';
if (schema.type === 'boolean') type = 'boolean';
const defVal =
type === 'string' ? "''" : type === 'number' ? '0' : type === 'boolean' ? 'false' : "''";
dynamicProps.push(`
{
displayName: 'Body ${name}',
name: 'body_${name}',
type: '${type}',
default: ${defVal},
placeholder: '${name}',
description: '${schema?.description ?? name}',
displayOptions: { show: { operation: ['${value}'], "@tool": [true] } }
}`);
}
}
return `[
${dropdown},
${dynamicProps.join(',\n ')}
]`;
}
// build execute switch
function buildExecuteSwitch(ops: Array<any>) {
const cases: string[] = [];
for (const op of ops) {
const val = operationValue(op.tag, op.operationId);
const method = (op.method || 'get').toLowerCase();
const url = op.path;
const q = op.query ?? [];
const bodyReq = op.body?.required ?? [];
const qLines =
q
.map(
(name: string) =>
`const query_${_.snakeCase(name)} = this.getNodeParameter('query_${_.snakeCase(name)}', i, '') as string;`,
)
.join('\n ') || '';
const bodyLines =
bodyReq
.map(
(name: string) =>
`const body_${_.snakeCase(name)} = this.getNodeParameter('body_${_.snakeCase(name)}', i, '') as any;`,
)
.join('\n ') || '';
const bodyObject =
bodyReq.length > 0
? `const body = { ${bodyReq.map((n: string) => `${_.snakeCase(n)}: body_${_.snakeCase(n)}`).join(', ')} };`
: 'const body = undefined;';
const paramsObj =
q.length > 0 ? `params: { ${q.map((n: string) => `${_.snakeCase(n)}: query_${_.snakeCase(n)}`).join(', ')} },` : '';
const dataLine = method === 'get' ? '' : 'data: body,';
cases.push(`
case '${val}': {
${qLines}
${bodyLines}
${bodyObject}
url = baseUrl + '${url}';
method = '${method}';
axiosConfig = {
headers: finalHeaders,
${paramsObj}
${dataLine}
};
break;
}
`);
}
return `
switch (operation) {
${cases.join('\n')}
default:
throw new Error('Unknown operation: ' + operation);
}
`;
}
function credentialsText() {
const text = `
import { ICredentialType, INodeProperties } from "n8n-workflow";
export class ${namespaceCase}Credentials implements ICredentialType {
name = "${CREDENTIAL_NAME}";
displayName = "${CREDENTIAL_NAME} (Bearer Token)";
properties: INodeProperties[] = [
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "",
placeholder: "https://api.example.com",
description: "Masukkan URL dasar API tanpa garis miring di akhir",
required: true,
},
{
displayName: "Bearer Token",
name: "token",
type: "string",
default: "",
typeOptions: { password: true },
description: "Masukkan token autentikasi Bearer (tanpa 'Bearer ' di depannya)",
required: true,
},
];
}
`
return text
}
// top-level
function generateNodeFile(ops: Array<any>) {
const propertiesBlock = buildPropertiesBlock(ops);
const executeSwitch = buildExecuteSwitch(ops);
return `import type { INodeType, INodeTypeDescription, IExecuteFunctions } from 'n8n-workflow';
import axios from 'axios';
export class ${namespaceCase} implements INodeType {
description: INodeTypeDescription = {
displayName: '${NAMESPACE}',
name: '${namespaceCase}',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Universal node generated from OpenAPI - satu node memuat semua endpoint',
defaults: { name: '${namespaceCase}' },
inputs: ['main'],
outputs: ['main'],
credentials: [
{ name: '${CREDENTIAL_NAME}', required: true }
],
properties: ${propertiesBlock}
};
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const returnData: any[] = [];
const creds = await this.getCredentials('${CREDENTIAL_NAME}') as any;
const baseUrlRaw = creds?.baseUrl ?? '';
const apiKeyRaw = creds?.token ?? '';
const baseUrl = String(baseUrlRaw || '').replace(/\\/$/, '');
const apiKey = String(apiKeyRaw || '').trim().replace(/^Bearer\\s+/i, '');
if (!baseUrl) throw new Error('Base URL tidak ditemukan');
if (!apiKey) throw new Error('Token tidak ditemukan');
for (let i = 0; i < items.length; i++) {
const operation = this.getNodeParameter('operation', i) as string;
let url = '';
let method: any = 'get';
let axiosConfig: any = {};
const finalHeaders: any = { Authorization: \`Bearer \${apiKey}\` };
${executeSwitch}
try {
const response = await axios({ method, url, ...axiosConfig });
returnData.push(response.data);
} catch (err: any) {
returnData.push({
error: true,
message: err.message,
status: err.response?.status,
data: err.response?.data,
});
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}
`;
}
function iconText(text: string) {
return `
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" aria-label="Icon AB square">
<defs>
<style>
.letters {
fill: #3b82f6; /* biru */
font-family: "Inter", "Segoe UI", Roboto, sans-serif;
font-weight: 800;
font-size: 56px;
}
</style>
</defs>
<text class="letters" x="64" y="78" text-anchor="middle" dominant-baseline="middle">${text}</text>
</svg>
`
}
function packageText({ name, className }: { name: string, className: string }) {
return `
{
"name": "n8n-nodes-${name}",
"version": "1.0.43",
"keywords": [
"n8n",
"n8n-nodes"
],
"author": {
"name": "makuro",
"phone": "6289697338821"
},
"license": "ISC",
"description": "",
"n8n": {
"nodes": [
"nodes/${className}.node.js"
],
"n8nNodesApiVersion": 1,
"credentials": [
"credentials/${className}Credentials.credentials.js"
]
}
}`
}
async function run() {
await fs.rm('src', { recursive: true }).catch(() => { })
await fs.mkdir('src/credentials', { recursive: true })
await fs.mkdir('src/nodes', { recursive: true })
console.log('💡 Loading OpenAPI...');
const api = await loadOpenAPI();
const ops: Array<any> = [];
for (const pathStr of Object.keys(api.paths || {})) {
const pathObj = api.paths[pathStr];
for (const method of Object.keys(pathObj)) {
const operation = pathObj[method];
const tags = operation.tags?.length ? operation.tags : ['default'];
console.log("✅", _.upperCase(method).padEnd(7), pathStr);
const operationId = operation.operationId || `${method}_${safe(pathStr)}`;
const query = (operation.parameters ?? [])
.filter((p: any) => p.in === 'query')
.map((p: any) => p.name);
const requestBody =
operation.requestBody?.content?.['application/json']?.schema ??
operation.requestBody?.content?.['multipart/form-data']?.schema ??
null;
const bodyRequired = requestBody?.required ?? [];
const bodyProps = requestBody?.properties ?? {};
for (const tag of tags) {
ops.push({
tag,
path: pathStr,
method,
operationId,
summary: operation.summary || '',
description: operation.description || '',
query,
body: {
required: bodyRequired,
schema: bodyProps,
},
});
}
}
}
if (ops.length === 0) throw new Error('No operations found');
const raw = generateNodeFile(ops);
console.log('[GEN] Generated single node file:', OUT_FILE);
await fs.writeFile(OUT_FILE, raw, 'utf-8');
const credentialsRaw = credentialsText();
await fs.writeFile(`src/credentials/${namespaceCase}Credentials.credentials.ts`, credentialsRaw, 'utf-8')
const iconRaw = iconText(_.upperCase(namespaceCase.substring(0, 3)));
await fs.writeFile(`src/nodes/icon.svg`, iconRaw, 'utf-8')
const packageRaw = packageText({ name: NAMESPACE!, className: namespaceCase });
await fs.writeFile(`src/package.json`, packageRaw, 'utf-8')
execSync('npx prettier --write src')
}
run()
.catch((err) => {
console.error(err);
process.exit(1);
})
.then(() => {
console.log('✅ Generated node file:', OUT_FILE);
process.exit(0);
})

61
bin/init.ts Executable file
View File

@@ -0,0 +1,61 @@
import fs from 'fs/promises';
import { execSync } from 'child_process'
async function init() {
console.log("[INIT] Initializing...")
try {
await fs.access(".env")
} catch (error) {
let envText = ""
envText += "NAMESPACE=\n"
envText += "OPENAPI_URL=\n"
// generate .env
await fs.writeFile(".env", envText)
console.log('[GEN] Generated .env');
}
const NAMESPACE = process.env.NAMESPACE
const OPENAPI_URL = process.env.OPENAPI_URL
if (!NAMESPACE || !OPENAPI_URL) {
throw new Error('NAMESPACE and OPENAPI_URL are required')
}
await fs.rmdir("src", { recursive: true }).catch(() => { })
try {
await fs.access(".git")
console.log("[INIT] Git already initialized")
execSync("rm -rf .git")
execSync("git init")
} catch (error) {
execSync("git init")
}
try {
console.log("[INIT] Gitignored...")
await fs.access(".gitignore")
} catch (e) {
execSync("npx -y gitignore node")
console.log('[INIT] gitignored');
}
try {
console.log("[INIT] Npmignored...")
await fs.access(".npmignore")
} catch (e) {
execSync("npx -y npmignore node")
console.log('[INIT] npmignored');
}
console.log('[INIT] Installing dependencies...');
execSync("bun install", { stdio: 'inherit' })
}
init()

3
bin/publish.ts Normal file
View File

@@ -0,0 +1,3 @@
import { execSync } from "child_process";
execSync("cd dist && npm publish", { stdio: 'inherit' })

32
bin/version_update.ts Normal file
View File

@@ -0,0 +1,32 @@
import { execSync } from "node:child_process";
import fs from "node:fs";
const NAMESPACE = process.env.NAMESPACE
if (!NAMESPACE) {
throw new Error('NAMESPACE is required')
}
// 1. Ambil versi remote dari npm
const remoteVersion = execSync(`npm view n8n-nodes-${NAMESPACE} version`)
.toString()
.trim();
console.log("🔍 Remote version:", remoteVersion);
// 2. Pecah versi → major.minor.patch
const [major, minor, patch] = remoteVersion.split(".").map(Number);
// 3. Generate versi baru: remote + 1 patch
const newLocalVersion = `${major}.${minor}.${patch + 1}`;
const pkgPath = "src/package.json";
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
const pkgPathDist = "dist/package.json";
const pkgDist = JSON.parse(fs.readFileSync(pkgPathDist, "utf8"));
pkg.version = newLocalVersion;
pkgDist.version = newLocalVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
fs.writeFileSync(pkgPathDist, JSON.stringify(pkgDist, null, 2));

1092
bun.lock Normal file

File diff suppressed because it is too large Load Diff

6151
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "n8n-starter",
"type": "module",
"private": true,
"version": "1.0.9",
"scripts": {
"init": "bun bin/init.ts",
"build": "bun bin/build.ts",
"generate": "bun bin/generate.ts",
"generate:build": "bun bin/generate.ts && bun bin/build.ts",
"version:update": "bun bin/version_update.ts",
"publish": "git add -A && git commit -m 'update version' && bun run version:update && tsc && bun bin/publish.ts",
"build:tsc": "tsc"
},
"dependencies": {
"axios": "^1.13.2",
"request": "^2.88.2"
},
"devDependencies": {
"@types/bun": "latest",
"@types/express": "^5.0.5",
"@types/lodash": "^4.17.20",
"@types/node": "^24.10.0",
"@types/request": "^2.48.13",
"@types/ssh2": "^1.15.5",
"dedent": "^1.7.0",
"express": "^5.1.0",
"lodash": "^4.17.21",
"n8n-core": "^1.117.1",
"n8n-workflow": "^1.116.0",
"nock": "^14.0.10",
"ssh2": "^1.17.0"
},
"peerDependencies": {
"typescript": "^5"
}
}

51
src/README.md Normal file
View File

@@ -0,0 +1,51 @@
# n8n-nodes-wajs
This is an n8n node to integrate with Wajs (WA-Resmi), allowing you to send various types of WhatsApp messages.
Wajs is a service that provides a WhatsApp API. You can find more information and documentation at [https://wa-resmi.com](https://wa-resmi.com).
## Installation
To use this node, you need to install it in your n8n setup.
1. Go to **Settings > Community Nodes**.
2. Select **Install**.
3. Enter `n8n-nodes-wajs` as the npm package name.
4. Agree to the risks and click **Install**.
n8n will restart, and the new node will be available in the editor.
## Credentials
To use this node, you need to configure your Wajs credentials.
1. Go to the **Credentials** section in n8n.
2. Click **Add credential**.
3. Search for **Wajs** and select it.
4. Fill in the following fields:
* **API Key**: Your Wajs API Key.
* **Device Key**: Your Wajs Device Key.
5. Click **Save**.
## Supported Operations
This node supports the following operations for the `Message` resource:
* **Send Text**: Send a plain text message.
* **Send Media**: Send media files like images, videos, audio, or documents.
* **Send Button**: Send a message with interactive buttons.
* **Send Template**: Send a pre-defined template message.
* **Send Location**: Send a map location.
* **Send Contact**: Send a contact card.
* **Send Reaction**: Send a reaction to a message.
* **Forward Message**: Forward an existing message.
* **Check Number**: Check if a phone number is valid and has a WhatsApp account.
## Author
* **Name**: makuro
* **Phone**: 6289697338821
## License
ISC

View File

@@ -0,0 +1,28 @@
import { ICredentialType, INodeProperties } from "n8n-workflow";
export class McpClientTool implements ICredentialType {
name = "mcptool";
displayName = "wajs (Bearer Token)";
properties: INodeProperties[] = [
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "",
placeholder: "https://api.example.com",
description: "Masukkan URL dasar API tanpa garis miring di akhir",
required: true,
},
{
displayName: "Bearer Token",
name: "token",
type: "string",
default: "",
typeOptions: { password: true },
description:
"Masukkan token autentikasi Bearer (tanpa 'Bearer ' di depannya)",
required: true,
},
];
}

View File

@@ -0,0 +1,512 @@
import {
type IDataObject,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type INodePropertyOptions,
type ILoadOptionsFunctions,
NodeOperationError,
} from 'n8n-workflow';
import { OptionsWithUri } from 'request';
/* -------------------------
Types
------------------------- */
interface MCPPayload {
name: string;
description?: string;
inputSchema?: IDataObject;
'x-props': {
method: string;
path: string;
operationId?: string;
tag?: string;
deprecated?: boolean;
summary?: string;
};
}
interface MCPToolsResponse {
tools: MCPPayload[];
}
/* -------------------------
Helper Functions
------------------------- */
/**
* Extract base URL and MCP path from endpoint
*/
function parseEndpoint(endpoint: string): { baseUrl: string; mcpPath: string } {
const trimmed = endpoint.trim().replace(/\/+$/, '');
const url = new URL(trimmed);
const pathname = url.pathname;
if (pathname && pathname !== '/') {
return {
baseUrl: `${url.protocol}//${url.host}`,
mcpPath: pathname
};
}
return {
baseUrl: trimmed,
mcpPath: ''
};
}
/**
* ✅ NEW: Enhance tool description with explicit parameter names and examples
*/
function enhanceToolDescription(tool: MCPPayload): string {
let desc = tool.description || tool['x-props'].summary || '';
// Add method and path info
desc += `\n\n**Endpoint**: ${tool['x-props'].method} ${tool['x-props'].path}`;
// ✅ CRITICAL: Add explicit parameter requirements
if (tool.inputSchema?.properties) {
const props = tool.inputSchema.properties as IDataObject;
const required = (tool.inputSchema.required as string[]) || [];
desc += '\n\n**Required Parameters (EXACT names must be used)**:';
const propEntries = Object.entries(props);
// List required fields first
const requiredFields = propEntries.filter(([key]) => required.includes(key));
const optionalFields = propEntries.filter(([key]) => !required.includes(key));
if (requiredFields.length > 0) {
requiredFields.forEach(([key, value]: [string, any]) => {
const type = value.type || 'string';
const valueDesc = value.description || '';
const example = value.example || value.examples?.[0];
desc += `\n • \`${key}\` (${type}, REQUIRED)`;
if (valueDesc) desc += ` - ${valueDesc}`;
if (example) desc += ` [Example: ${JSON.stringify(example)}]`;
});
}
if (optionalFields.length > 0) {
desc += '\n\n**Optional Parameters**:';
optionalFields.forEach(([key, value]: [string, any]) => {
const type = value.type || 'string';
const valueDesc = value.description || '';
const example = value.example || value.examples?.[0];
desc += `\n • \`${key}\` (${type}, optional)`;
if (valueDesc) desc += ` - ${valueDesc}`;
if (example) desc += ` [Example: ${JSON.stringify(example)}]`;
});
}
// ✅ Add critical note
desc += '\n\n⚠ **IMPORTANT**: You MUST use the EXACT parameter names shown above. Do not abbreviate or translate them.';
}
return desc;
}
/**
* ✅ NEW: Add example to schema for better AI understanding
*/
function enhanceInputSchema(schema: IDataObject): IDataObject {
if (!schema?.properties) return schema;
const enhanced = { ...schema };
const props = enhanced.properties as IDataObject;
const required = (enhanced.required as string[]) || [];
// Build example object
const exampleObj: IDataObject = {};
for (const [key, value] of Object.entries(props)) {
const prop = value as any;
if (prop.example !== undefined) {
exampleObj[key] = prop.example;
} else if (prop.examples && Array.isArray(prop.examples) && prop.examples.length > 0) {
exampleObj[key] = prop.examples[0];
} else if (prop.default !== undefined) {
exampleObj[key] = prop.default;
} else {
// Generate example based on type
switch (prop.type) {
case 'string':
exampleObj[key] = `example_${key}`;
break;
case 'number':
case 'integer':
exampleObj[key] = 123;
break;
case 'boolean':
exampleObj[key] = true;
break;
case 'array':
exampleObj[key] = [];
break;
case 'object':
exampleObj[key] = {};
break;
default:
exampleObj[key] = `value_for_${key}`;
}
}
}
// Add example to schema root
enhanced.example = exampleObj;
// Add title to each property emphasizing exact naming
for (const [key, value] of Object.entries(props)) {
const prop = value as any;
if (!prop.title) {
prop.title = `Parameter: ${key}`;
}
// Add explicit note about exact naming
if (prop.description) {
prop.description = `${prop.description}\n(Use exact name: "${key}")`;
} else {
prop.description = `Use exact parameter name: "${key}"`;
}
}
return enhanced;
}
/* -------------------------
Node Implementation
------------------------- */
export class McpClientTool implements INodeType {
description: INodeTypeDescription = {
displayName: 'MCP Client Tool',
name: 'mcpClientTool',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
usableAsTool: true,
description:
'Dynamically executes tools from an MCP server based on AI agent input. Fetches tool metadata from /tools and provides dynamic tool definitions for agents.',
defaults: { name: 'MCP Client' },
inputs: ['main'],
outputs: ['main'],
credentials: [{ name: 'mcptool', required: false }],
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
default: 'http://localhost:3000/mcp',
required: true,
description: 'MCP server endpoint. Can include path (e.g., /mcp) for metadata endpoints.',
},
{
displayName: 'Server Transport',
name: 'transport',
type: 'options',
options: [
{ name: 'HTTP (Streamable)', value: 'http' },
{ name: 'Server Sent Events (Deprecated)', value: 'sse' },
],
default: 'http',
},
{
displayName: 'Tools to Include',
name: 'toolSelection',
type: 'options',
options: [
{ name: 'All', value: 'all' },
{ name: 'Selected', value: 'selected' },
{ name: 'All Except', value: 'except' },
],
default: 'all',
},
{
displayName: 'Selected Tools',
name: 'selectedTools',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getMcpTools',
},
default: [],
displayOptions: {
show: {
toolSelection: ['selected', 'except'],
},
},
},
{
displayName: 'Strict Parameter Validation',
name: 'strictValidation',
type: 'boolean',
default: true,
description: 'Whether to validate that all required parameters are provided with exact names',
},
],
};
methods = {
loadOptions: {
async getMcpTools(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader: any = '';
try {
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
const requestOptions: OptionsWithUri = {
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
};
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request(requestOptions);
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid MCP /tools response');
}
return toolsRes.tools.map((tool) => ({
name: `${tool.name}${tool.description || tool['x-props'].summary || ''}`,
value: tool.name,
}));
},
},
loadMethods: {
async getAiToolDefinitions(this: any) {
const endpoint = this.getNodeParameter?.('endpoint', 0) as string;
if (!endpoint) return [];
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
const credentials = await this.getCredentials?.('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await (this as any).helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch {
return [];
}
if (!Array.isArray(toolsRes?.tools)) return [];
const mode = this.getNodeParameter?.('toolSelection', 0) as string;
const selected = this.getNodeParameter?.('selectedTools', 0) as string[];
const filtered = toolsRes.tools.filter((t) => {
if (mode === 'all') return true;
if (mode === 'selected') return selected.includes(t.name);
if (mode === 'except') return !selected.includes(t.name);
return true;
});
// ✅ ENHANCED: Add detailed descriptions and examples
return filtered.map((t) => {
const enhancedSchema = enhanceInputSchema(
t.inputSchema && typeof t.inputSchema === 'object'
? t.inputSchema
: { type: 'object', properties: {} }
);
return {
name: t.name,
description: enhanceToolDescription(t),
parameters: enhancedSchema,
__meta: { ...t['x-props'] },
};
});
},
},
};
async execute(this: any): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const output: INodeExecutionData[] = [];
const endpoint = this.getNodeParameter('endpoint', 0) as string;
const strictValidation = this.getNodeParameter('strictValidation', 0, true) as boolean;
const { baseUrl, mcpPath } = parseEndpoint(endpoint);
let authHeader = '';
try {
const credentials = await this.getCredentials('mcptool');
if (credentials?.authHeader) authHeader = credentials.authHeader;
} catch {}
const toolsUrl = `${baseUrl}${mcpPath}/tools`;
let toolsRes: MCPToolsResponse;
try {
toolsRes = await this.helpers.request({
method: 'GET',
uri: toolsUrl,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 15000,
});
} catch (err) {
throw new NodeOperationError(
this.getNode(),
`Failed to fetch MCP tools during execution from ${toolsUrl}: ${(err as Error).message}`,
);
}
if (!toolsRes?.tools) {
throw new NodeOperationError(this.getNode(), 'Invalid tools list from MCP server');
}
const toolMap = new Map<string, MCPPayload>();
for (const t of toolsRes.tools) toolMap.set(t.name, t);
for (const item of items) {
try {
const json = item.json || {};
const toolName = json.tool || json.toolName || json.tool_name || null;
if (!toolName) {
throw new NodeOperationError(
this.getNode(),
`Missing "tool" field in input JSON. Expected { "tool": "<name>", "arguments": { ... } }`,
);
}
const tool = toolMap.get(toolName);
if (!tool) {
throw new NodeOperationError(
this.getNode(),
`Tool "${toolName}" not found. Available: ${[...toolMap.keys()].join(', ')}`,
);
}
let args: IDataObject = json.arguments || json.args || json.parameters || {};
// ✅ NEW: Validate required parameters if strict mode enabled
if (strictValidation && tool.inputSchema?.required) {
const required = tool.inputSchema.required as string[];
const missing = required.filter(key => !(key in args));
if (missing.length > 0) {
const schema = tool.inputSchema.properties as IDataObject;
const details = missing.map(key => {
const prop = schema[key] as any;
const type = prop?.type || 'unknown';
const desc = prop?.description || 'No description';
return `${key} (${type}): ${desc}`;
}).join('\n');
throw new NodeOperationError(
this.getNode(),
`Missing required parameters for tool "${toolName}":\n${details}\n\nProvided: ${JSON.stringify(Object.keys(args))}\nRequired: ${JSON.stringify(required)}`,
);
}
}
// ✅ NEW: Log parameter mapping for debugging
if (tool.inputSchema?.properties) {
const expected = Object.keys(tool.inputSchema.properties);
const provided = Object.keys(args);
const unexpected = provided.filter(k => !expected.includes(k));
if (unexpected.length > 0) {
console.warn(
`Tool "${toolName}" received unexpected parameters: ${unexpected.join(', ')}. ` +
`Expected: ${expected.join(', ')}`
);
}
}
const method = (tool['x-props'].method || 'GET').toUpperCase();
let path = tool['x-props'].path || '/';
if (!path.startsWith('/')) path = `/${path}`;
const url = `${baseUrl}${path}`;
const req: OptionsWithUri = {
method,
uri: url,
json: true,
headers: authHeader ? { Authorization: authHeader } : {},
timeout: 20000,
};
if (method === 'GET' || method === 'DELETE') {
req.qs = args;
} else {
req.body = args;
}
let response: any;
try {
response = await this.helpers.request(req);
} catch (err: any) {
// ✅ ENHANCED: Better error messages
let errorMsg = `Error calling tool "${toolName}" at ${url}: ${err.message || err}`;
if (err.statusCode === 400) {
errorMsg += `\n\nThis might be due to incorrect parameter names or types.`;
errorMsg += `\nProvided parameters: ${JSON.stringify(args, null, 2)}`;
if (tool.inputSchema?.properties) {
errorMsg += `\nExpected parameters: ${JSON.stringify(Object.keys(tool.inputSchema.properties))}`;
}
}
throw new NodeOperationError(this.getNode(), errorMsg);
}
output.push({
json: {
error: false,
tool: toolName,
method,
path,
url,
response,
},
});
} catch (error) {
output.push({
json: {
error: true,
message: (error as Error).message,
input: item.json,
},
});
}
}
return [output];
}
}

17
src/nodes/icon.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" aria-label="Icon AB square">
<defs>
<style>
.letters {
fill: #3b82f6; /* biru */
font-family: "Inter", "Segoe UI", Roboto, sans-serif;
font-weight: 800;
font-size: 56px;
}
</style>
</defs>
<text class="letters" x="64" y="78" text-anchor="middle" dominant-baseline="middle">WAJ</text>
</svg>

After

Width:  |  Height:  |  Size: 434 B

23
src/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "n8n-nodes-mcp-tool",
"version": "1.0.97",
"keywords": [
"n8n",
"n8n-nodes"
],
"author": {
"name": "makuro",
"phone": "6289697338821"
},
"license": "ISC",
"description": "",
"n8n": {
"nodes": [
"nodes/McpClientTool.node.js"
],
"n8nNodesApiVersion": 1,
"credentials": [
"credentials/McpClientTool.credentials.js"
]
}
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["es2021", "dom"],
"module": "commonjs",
"target": "es2017",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/nodes/**/*.svg"]
}

444
xx.md Normal file
View File

@@ -0,0 +1,444 @@
analisa , berikut adalah step by step tool dijalankan
step 1 :
input :
[
{
"sessionId":
"086360e7bf0441ae8c84553044c69b2f",
"action":
"sendMessage",
"chatInput":
"berikan list kategori",
"tool":
"list_kategori_pengaduan",
"arguments":
{
},
"id":
"call_1h2JYh5gXEPK4A3NPMibQBlP",
"toolCallId":
"call_HuRfep8zyimaAmdkhDFPryHx"
}
]
output :
[
{
"error":
false,
"tool":
"list_kategori_pengaduan",
"method":
"GET",
"path":
"/api/pengaduan/category",
"url":
"https://cld-dkr-prod-jenna-mcp.wibudev.com/api/pengaduan/category",
"response":
{
"data":
[
{
"id":
"infrastruktur",
"name":
"Infrastruktur"
},
{
"id":
"cmhslcvcy0000mg0810l7zx8x",
"name":
"keamanan"
},
{
"id":
"cmi797plp0005mg08a3i5s1l0",
"name":
"Keamanan"
},
{
"id":
"keamanan",
"name":
"Keamanan"
},
{
"id":
"kebersihan",
"name":
"Kebersihan"
},
{
step 2 :
input :
[
{
"sessionId":
"086360e7bf0441ae8c84553044c69b2f",
"action":
"sendMessage",
"chatInput":
"title sampah menumpuk",
"tool":
"buat_pengaduan_warga",
"arguments":
{
"category_id":
"sampah",
"name":
"malik",
"phone":
"089697887766",
"location":
"jalan pandaan nomer 40",
"description":
"sampah sangat menggunung",
"title":
"sampah menumpuk"
},
"toolCallId":
"call_AM8heHmc3UUD4MVte3if3Jd0"
}
]
output :
[
{
"error":
true,
"message":
"Error calling tool \"buat_pengaduan_warga\" at https://cld-dkr-prod-jenna-mcp.wibudev.com/api/pengaduan/create: 422 - \"Judul pengaduan harus diisi\"",
"input":
{
"sessionId":
"086360e7bf0441ae8c84553044c69b2f",
"action":
"sendMessage",
"chatInput":
"title sampah menumpuk",
"tool":
"buat_pengaduan_warga",
"arguments":
{
"category_id":
"sampah",
"name":
"malik",
"phone":
"089697887766",
"location":
"jalan pandaan nomer 40",
"description":
"sampah sangat menggunung",
"title":
"sampah menumpuk"
},
"toolCallId":
"call_AM8heHmc3UUD4MVte3if3Jd0"
}
}
]
padahal ini endpont list tool dari mcp server :
{
"name": "buat_pengaduan_warga",
"description": "Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga\n\n Execute POST /api/pengaduan/create",
"inputSchema": {
"type": "object",
"properties": {
"judulPengaduan": {
"type": "string",
"description": "Judul singkat dari pengaduan warga",
"examples": [
"Sampah menumpuk di depan rumah"
]
},
"detailPengaduan": {
"type": "string",
"description": "Penjelasan lebih detail mengenai pengaduan",
"examples": [
"Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"
]
},
"lokasi": {
"type": "string",
"description": "Alamat atau titik lokasi pengaduan",
"examples": [
"Jl. Raya No. 1, RT 01 RW 02, Darmasaba"
]
},
"namaGambar": {
"type": "string",
"description": "Nama file gambar yang telah diupload (opsional)",
"examples": [
"sampah.jpg"
]
},
"kategoriId": {
"type": "string",
"description": "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)",
"examples": [
"kebersihan"
]
},
"namaWarga": {
"type": "string",
"description": "Nama warga yang melapor",
"examples": [
"budiman"
]
},
"noTelepon": {
"type": "string",
"description": "Nomor telepon warga pelapor",
"examples": [
"08123456789",
"+628123456789"
]
}
},
"required": [
"judulPengaduan",
"detailPengaduan",
"lokasi",
"namaWarga",
"noTelepon"
],
"additionalProperties": false
},
"x-props": {
"method": "POST",
"path": "/api/pengaduan/create",
"operationId": "postApiPengaduanCreate",
"tag": "mcp",
"deprecated": false,
"summary": "Buat Pengaduan Warga"
}
}
artinya ai tidak membaca enpoin dan argument yang diperlukan , secara struktur argumennya terpenuhi namun key tidak sama persis yang menyebabkan error