tambahan
This commit is contained in:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal 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
142
.npmignore
Normal 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/
|
||||||
260
McpClientTool.node.ts.v2.txt
Normal file
260
McpClientTool.node.ts.v2.txt
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
361
McpClientTool.node.ts.v3.txt
Normal file
361
McpClientTool.node.ts.v3.txt
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
377
McpClientTool.node.ts.v4.txt
Normal file
377
McpClientTool.node.ts.v4.txt
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
377
McpClientTool.node.ts.v5.txt
Normal file
377
McpClientTool.node.ts.v5.txt
Normal 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
141
McpClientTool.node.txt
Normal 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
377
McpClientTool.node.v1.txt
Normal 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
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# n8n Node: Wajs
|
||||||
|
|
||||||
|
Ini adalah *community node* n8n untuk berinteraksi dengan layanan Wajs.
|
||||||
|
|
||||||
|
[](https://n8n.io)
|
||||||
|
[](https://www.npmjs.com/package/n8n-nodes-wajs)
|
||||||
|
[](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
87
bin/build.ts
Executable 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
408
bin/generate.ts
Executable 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
61
bin/init.ts
Executable 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
3
bin/publish.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
execSync("cd dist && npm publish", { stdio: 'inherit' })
|
||||||
32
bin/version_update.ts
Normal file
32
bin/version_update.ts
Normal 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));
|
||||||
6151
package-lock.json
generated
Normal file
6151
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
51
src/README.md
Normal 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
|
||||||
28
src/credentials/McpClientTool.credentials.ts
Normal file
28
src/credentials/McpClientTool.credentials.ts
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
512
src/nodes/McpClientTool.node.ts
Normal file
512
src/nodes/McpClientTool.node.ts
Normal 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
17
src/nodes/icon.svg
Normal 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
23
src/package.json
Normal 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
16
tsconfig.json
Normal 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
444
xx.md
Normal 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
|
||||||
Reference in New Issue
Block a user