mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-29 21:53:07 +00:00
feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support (#460)
* feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support - Rename tool from n8n_trigger_webhook_workflow to n8n_test_workflow - Add support for webhook, form, and chat triggers (auto-detection) - Implement modular trigger system with registry pattern - Add trigger detector for automatic trigger type inference - Remove execute trigger type (n8n public API limitation) - Add comprehensive tests for trigger detection and handlers The tool now auto-detects trigger type from workflow structure and supports all externally-triggerable workflows via n8n's public API. Note: Direct workflow execution (Schedule/Manual triggers) requires n8n's instance-level MCP access, not available via REST API. Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add SSRF protection to webhook handler and update tests - Add SSRF URL validation to webhook-handler.ts (critical security fix) Aligns with existing SSRF protection in form-handler.ts and chat-handler.ts - Update parameter-validation.test.ts to use new n8n_test_workflow tool name Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: n8n_test_workflow unified trigger tool (v2.28.0) Added new `n8n_test_workflow` tool replacing `n8n_trigger_webhook_workflow`: Features: - Auto-detects trigger type (webhook/form/chat) from workflow - Supports multiple trigger types with type-specific parameters - SSRF protection for all trigger handlers - Extensible handler architecture with registry pattern Changes: - Fixed Zod schema to remove invalid 'execute' trigger type - Updated README.md tool documentation - Added CHANGELOG entry for v2.28.0 - Bumped version to 2.28.0 Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add comprehensive unit tests for trigger handlers Added 87 unit tests across 4 test files to improve code coverage: - base-handler.test.ts (19 tests) - 100% coverage - webhook-handler.test.ts (22 tests) - 100% coverage - chat-handler.test.ts (23 tests) - 100% coverage - form-handler.test.ts (23 tests) - 100% coverage Tests cover: - Input validation and parameter handling - SSRF protection integration - HTTP method handling and URL building - Error response formatting - Execution paths for all trigger types Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ddf9556759
commit
33690c5650
149
src/triggers/handlers/base-handler.ts
Normal file
149
src/triggers/handlers/base-handler.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Base trigger handler - abstract class for all trigger handlers
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Workflow } from '../../types/n8n-api';
|
||||
import { InstanceContext } from '../../types/instance-context';
|
||||
import { N8nApiClient } from '../../services/n8n-api-client';
|
||||
import { getN8nApiConfig } from '../../config/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
BaseTriggerInput,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Constructor type for trigger handlers
|
||||
*/
|
||||
export type TriggerHandlerConstructor = new (
|
||||
client: N8nApiClient,
|
||||
context?: InstanceContext
|
||||
) => BaseTriggerHandler;
|
||||
|
||||
/**
|
||||
* Abstract base class for all trigger handlers
|
||||
*
|
||||
* Each handler implements:
|
||||
* - Input validation via Zod schema
|
||||
* - Capability declaration (active workflow required, etc.)
|
||||
* - Execution logic specific to the trigger type
|
||||
*/
|
||||
export abstract class BaseTriggerHandler<T extends BaseTriggerInput = BaseTriggerInput> {
|
||||
protected client: N8nApiClient;
|
||||
protected context?: InstanceContext;
|
||||
|
||||
/** The trigger type this handler supports */
|
||||
abstract readonly triggerType: TriggerType;
|
||||
|
||||
/** Handler capabilities */
|
||||
abstract readonly capabilities: TriggerHandlerCapabilities;
|
||||
|
||||
/** Zod schema for input validation */
|
||||
abstract readonly inputSchema: z.ZodSchema<T>;
|
||||
|
||||
constructor(client: N8nApiClient, context?: InstanceContext) {
|
||||
this.client = client;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input against schema
|
||||
* @throws ZodError if validation fails
|
||||
*/
|
||||
validate(input: unknown): T {
|
||||
return this.inputSchema.parse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the trigger
|
||||
*
|
||||
* @param input - Validated trigger input
|
||||
* @param workflow - The workflow being triggered
|
||||
* @param triggerInfo - Detected trigger information (may be undefined for 'execute' type)
|
||||
*/
|
||||
abstract execute(
|
||||
input: T,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse>;
|
||||
|
||||
/**
|
||||
* Get the n8n instance base URL from context or environment config
|
||||
*/
|
||||
protected getBaseUrl(): string | undefined {
|
||||
// First try context (for multi-tenant scenarios)
|
||||
if (this.context?.n8nApiUrl) {
|
||||
return this.context.n8nApiUrl.replace(/\/api\/v1\/?$/, '');
|
||||
}
|
||||
// Fallback to environment config
|
||||
const config = getN8nApiConfig();
|
||||
if (config?.baseUrl) {
|
||||
return config.baseUrl.replace(/\/api\/v1\/?$/, '');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the n8n API key from context or environment config
|
||||
*/
|
||||
protected getApiKey(): string | undefined {
|
||||
// First try context (for multi-tenant scenarios)
|
||||
if (this.context?.n8nApiKey) {
|
||||
return this.context.n8nApiKey;
|
||||
}
|
||||
// Fallback to environment config
|
||||
const config = getN8nApiConfig();
|
||||
return config?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize response to unified format
|
||||
*/
|
||||
protected normalizeResponse(
|
||||
result: unknown,
|
||||
input: T,
|
||||
startTime: number,
|
||||
extra?: Partial<TriggerResponse>
|
||||
): TriggerResponse {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
data: result,
|
||||
metadata: {
|
||||
duration,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
protected errorResponse(
|
||||
input: BaseTriggerInput,
|
||||
error: string,
|
||||
startTime: number,
|
||||
extra?: Partial<TriggerResponse>
|
||||
): TriggerResponse {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
error,
|
||||
metadata: {
|
||||
duration,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
}
|
||||
141
src/triggers/handlers/chat-handler.ts
Normal file
141
src/triggers/handlers/chat-handler.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Chat trigger handler
|
||||
*
|
||||
* Handles chat-based workflow triggers:
|
||||
* - POST to webhook endpoint with chat payload
|
||||
* - Payload structure: { action: 'sendMessage', sessionId, chatInput }
|
||||
* - Sync mode only (no SSE streaming)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Workflow } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
ChatTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
import { buildTriggerUrl } from '../trigger-detector';
|
||||
|
||||
/**
|
||||
* Zod schema for chat input validation
|
||||
*/
|
||||
const chatInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('chat'),
|
||||
message: z.string(),
|
||||
sessionId: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat trigger handler
|
||||
*/
|
||||
export class ChatHandler extends BaseTriggerHandler<ChatTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'chat';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = chatInputSchema;
|
||||
|
||||
async execute(
|
||||
input: ChatTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build chat webhook URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Use trigger info to build URL or fallback to default pattern
|
||||
let chatUrl: string;
|
||||
if (triggerInfo?.webhookPath) {
|
||||
chatUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
|
||||
} else {
|
||||
// Default chat webhook path pattern
|
||||
chatUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.workflowId}`;
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(chatUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Generate or use provided session ID
|
||||
const sessionId = input.sessionId || generateSessionId();
|
||||
|
||||
// Build chat payload
|
||||
const chatPayload = {
|
||||
action: 'sendMessage',
|
||||
sessionId,
|
||||
chatInput: input.message,
|
||||
// Include any additional data
|
||||
...input.data,
|
||||
};
|
||||
|
||||
// Build request config
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: chatUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...input.headers,
|
||||
},
|
||||
data: chatPayload,
|
||||
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
// Make the request (sync mode - no streaming)
|
||||
const response = await axios.request(config);
|
||||
|
||||
// Extract the chat response
|
||||
const chatResponse = response.data;
|
||||
|
||||
return this.normalizeResponse(chatResponse, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
sessionId,
|
||||
webhookPath: triggerInfo?.webhookPath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.response?.data;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: errorDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/triggers/handlers/form-handler.ts
Normal file
117
src/triggers/handlers/form-handler.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Form trigger handler
|
||||
*
|
||||
* Handles form-based workflow triggers:
|
||||
* - POST to /form/<workflowId> or /form-test/<workflowId>
|
||||
* - Passes form fields as request body
|
||||
* - Workflow must be active (for production endpoint)
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Workflow, WebhookRequest } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
FormTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
|
||||
/**
|
||||
* Zod schema for form input validation
|
||||
*/
|
||||
const formInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('form'),
|
||||
formData: z.record(z.unknown()).optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form trigger handler
|
||||
*/
|
||||
export class FormHandler extends BaseTriggerHandler<FormTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'form';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = formInputSchema;
|
||||
|
||||
async execute(
|
||||
input: FormTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build form URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Form triggers use /form/<path> endpoint
|
||||
// The path can be from trigger info or workflow ID
|
||||
const formPath = triggerInfo?.node?.parameters?.path || input.workflowId;
|
||||
const formUrl = `${baseUrl.replace(/\/+$/, '')}/form/${formPath}`;
|
||||
|
||||
// Merge formData and data (formData takes precedence)
|
||||
const formFields = {
|
||||
...input.data,
|
||||
...input.formData,
|
||||
};
|
||||
|
||||
// SSRF protection
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(formUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Build request config
|
||||
const config: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: formUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...input.headers,
|
||||
},
|
||||
data: formFields,
|
||||
timeout: input.timeout || (input.waitForResponse !== false ? 120000 : 30000),
|
||||
validateStatus: (status) => status < 500,
|
||||
};
|
||||
|
||||
// Make the request
|
||||
const response = await axios.request(config);
|
||||
|
||||
return this.normalizeResponse(response.data, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.response?.data;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: errorDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/triggers/handlers/webhook-handler.ts
Normal file
125
src/triggers/handlers/webhook-handler.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Webhook trigger handler
|
||||
*
|
||||
* Handles webhook-based workflow triggers:
|
||||
* - Supports GET, POST, PUT, DELETE methods
|
||||
* - Passes data as body (POST/PUT/DELETE) or query params (GET)
|
||||
* - Includes SSRF protection
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Workflow, WebhookRequest } from '../../types/n8n-api';
|
||||
import {
|
||||
TriggerType,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
WebhookTriggerInput,
|
||||
} from '../types';
|
||||
import { BaseTriggerHandler } from './base-handler';
|
||||
import { buildTriggerUrl } from '../trigger-detector';
|
||||
|
||||
/**
|
||||
* Zod schema for webhook input validation
|
||||
*/
|
||||
const webhookInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('webhook'),
|
||||
httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
timeout: z.number().optional(),
|
||||
waitForResponse: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Webhook trigger handler
|
||||
*/
|
||||
export class WebhookHandler extends BaseTriggerHandler<WebhookTriggerInput> {
|
||||
readonly triggerType: TriggerType = 'webhook';
|
||||
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
supportedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
canPassInputData: true,
|
||||
};
|
||||
|
||||
readonly inputSchema = webhookInputSchema;
|
||||
|
||||
async execute(
|
||||
input: WebhookTriggerInput,
|
||||
workflow: Workflow,
|
||||
triggerInfo?: DetectedTrigger
|
||||
): Promise<TriggerResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Build webhook URL
|
||||
const baseUrl = this.getBaseUrl();
|
||||
if (!baseUrl) {
|
||||
return this.errorResponse(input, 'Cannot determine n8n base URL', startTime);
|
||||
}
|
||||
|
||||
// Use provided webhook path or extract from trigger info
|
||||
let webhookUrl: string;
|
||||
if (input.webhookPath) {
|
||||
// User provided explicit path
|
||||
webhookUrl = `${baseUrl.replace(/\/+$/, '')}/webhook/${input.webhookPath}`;
|
||||
} else if (triggerInfo?.webhookPath) {
|
||||
// Use detected path from workflow
|
||||
webhookUrl = buildTriggerUrl(baseUrl, triggerInfo, 'production');
|
||||
} else {
|
||||
return this.errorResponse(
|
||||
input,
|
||||
'No webhook path available. Provide webhookPath parameter or ensure workflow has a webhook trigger.',
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
// Determine HTTP method
|
||||
const httpMethod = input.httpMethod || triggerInfo?.httpMethod || 'POST';
|
||||
|
||||
// SSRF protection - validate the webhook URL before making the request
|
||||
const { SSRFProtection } = await import('../../utils/ssrf-protection');
|
||||
const validation = await SSRFProtection.validateWebhookUrl(webhookUrl);
|
||||
if (!validation.valid) {
|
||||
return this.errorResponse(input, `SSRF protection: ${validation.reason}`, startTime);
|
||||
}
|
||||
|
||||
// Build webhook request
|
||||
const webhookRequest: WebhookRequest = {
|
||||
webhookUrl,
|
||||
httpMethod: httpMethod as 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
data: input.data,
|
||||
headers: input.headers,
|
||||
waitForResponse: input.waitForResponse ?? true,
|
||||
};
|
||||
|
||||
// Trigger the webhook
|
||||
const response = await this.client.triggerWebhook(webhookRequest);
|
||||
|
||||
return this.normalizeResponse(response, input, startTime, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
metadata: {
|
||||
duration: Date.now() - startTime,
|
||||
webhookPath: input.webhookPath || triggerInfo?.webhookPath,
|
||||
httpMethod,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Try to extract execution ID from error if available
|
||||
const errorDetails = (error as any)?.details;
|
||||
const executionId = errorDetails?.executionId || errorDetails?.id;
|
||||
|
||||
return this.errorResponse(input, errorMessage, startTime, {
|
||||
executionId,
|
||||
code: (error as any)?.code,
|
||||
details: errorDetails,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/triggers/index.ts
Normal file
46
src/triggers/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Trigger system for n8n_test_workflow tool
|
||||
*
|
||||
* Provides extensible trigger handling for different n8n trigger types:
|
||||
* - webhook: HTTP-based triggers
|
||||
* - form: Form submission triggers
|
||||
* - chat: Chat/AI triggers
|
||||
*
|
||||
* Note: n8n's public API does not support direct workflow execution.
|
||||
* Only workflows with these trigger types can be triggered externally.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
TriggerType,
|
||||
BaseTriggerInput,
|
||||
WebhookTriggerInput,
|
||||
FormTriggerInput,
|
||||
ChatTriggerInput,
|
||||
TriggerInput,
|
||||
TriggerResponse,
|
||||
TriggerHandlerCapabilities,
|
||||
DetectedTrigger,
|
||||
TriggerDetectionResult,
|
||||
TestWorkflowInput,
|
||||
} from './types';
|
||||
|
||||
// Detector
|
||||
export {
|
||||
detectTriggerFromWorkflow,
|
||||
buildTriggerUrl,
|
||||
describeTrigger,
|
||||
} from './trigger-detector';
|
||||
|
||||
// Registry
|
||||
export {
|
||||
TriggerRegistry,
|
||||
initializeTriggerRegistry,
|
||||
ensureRegistryInitialized,
|
||||
} from './trigger-registry';
|
||||
|
||||
// Base handler
|
||||
export {
|
||||
BaseTriggerHandler,
|
||||
TriggerHandlerConstructor,
|
||||
} from './handlers/base-handler';
|
||||
301
src/triggers/trigger-detector.ts
Normal file
301
src/triggers/trigger-detector.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Trigger detector - analyzes workflows to detect trigger type
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../types/n8n-api';
|
||||
import { normalizeNodeType } from '../utils/node-type-utils';
|
||||
import { TriggerType, DetectedTrigger, TriggerDetectionResult } from './types';
|
||||
|
||||
/**
|
||||
* Node type patterns for each trigger type
|
||||
*/
|
||||
const WEBHOOK_PATTERNS = [
|
||||
'webhook',
|
||||
'webhooktrigger',
|
||||
];
|
||||
|
||||
const FORM_PATTERNS = [
|
||||
'formtrigger',
|
||||
'form',
|
||||
];
|
||||
|
||||
const CHAT_PATTERNS = [
|
||||
'chattrigger',
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the trigger type from a workflow
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Webhook trigger (most common for API access)
|
||||
* 2. Chat trigger (AI-specific)
|
||||
* 3. Form trigger
|
||||
*
|
||||
* Note: n8n's public API does not support direct workflow execution.
|
||||
* Only workflows with webhook/form/chat triggers can be triggered externally.
|
||||
*/
|
||||
export function detectTriggerFromWorkflow(workflow: Workflow): TriggerDetectionResult {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return {
|
||||
detected: false,
|
||||
reason: 'Workflow has no nodes',
|
||||
};
|
||||
}
|
||||
|
||||
// Find all trigger nodes
|
||||
const triggerNodes = workflow.nodes.filter(node => !node.disabled && isTriggerNodeType(node.type));
|
||||
|
||||
if (triggerNodes.length === 0) {
|
||||
return {
|
||||
detected: false,
|
||||
reason: 'No trigger nodes found in workflow',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for specific trigger types in priority order
|
||||
for (const node of triggerNodes) {
|
||||
const webhookTrigger = detectWebhookTrigger(node);
|
||||
if (webhookTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: webhookTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of triggerNodes) {
|
||||
const chatTrigger = detectChatTrigger(node);
|
||||
if (chatTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: chatTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of triggerNodes) {
|
||||
const formTrigger = detectFormTrigger(node);
|
||||
if (formTrigger) {
|
||||
return {
|
||||
detected: true,
|
||||
trigger: formTrigger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No externally-triggerable trigger found
|
||||
return {
|
||||
detected: false,
|
||||
reason: `Workflow has trigger nodes but none support external triggering (found: ${triggerNodes.map(n => n.type).join(', ')}). Only webhook, form, and chat triggers can be triggered via the API.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is a trigger
|
||||
*/
|
||||
function isTriggerNodeType(nodeType: string): boolean {
|
||||
const normalized = normalizeNodeType(nodeType).toLowerCase();
|
||||
return (
|
||||
normalized.includes('trigger') ||
|
||||
normalized.includes('webhook') ||
|
||||
normalized === 'nodes-base.start'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect webhook trigger and extract configuration
|
||||
*/
|
||||
function detectWebhookTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isWebhook = WEBHOOK_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isWebhook) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract webhook path from parameters
|
||||
const params = node.parameters || {};
|
||||
const webhookPath = extractWebhookPath(params, node.id);
|
||||
const httpMethod = extractHttpMethod(params);
|
||||
|
||||
return {
|
||||
type: 'webhook',
|
||||
node,
|
||||
webhookPath,
|
||||
httpMethod,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect form trigger and extract configuration
|
||||
*/
|
||||
function detectFormTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isForm = FORM_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract form fields from parameters
|
||||
const params = node.parameters || {};
|
||||
const formFields = extractFormFields(params);
|
||||
|
||||
return {
|
||||
type: 'form',
|
||||
node,
|
||||
formFields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect chat trigger and extract configuration
|
||||
*/
|
||||
function detectChatTrigger(node: WorkflowNode): DetectedTrigger | null {
|
||||
const normalized = normalizeNodeType(node.type).toLowerCase();
|
||||
const nodeName = normalized.split('.').pop() || '';
|
||||
|
||||
const isChat = CHAT_PATTERNS.some(pattern =>
|
||||
nodeName === pattern || nodeName.includes(pattern)
|
||||
);
|
||||
|
||||
if (!isChat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract chat configuration
|
||||
const params = node.parameters || {};
|
||||
const responseMode = (params.options as any)?.responseMode || 'lastNode';
|
||||
const webhookPath = extractWebhookPath(params, node.id);
|
||||
|
||||
return {
|
||||
type: 'chat',
|
||||
node,
|
||||
webhookPath,
|
||||
chatConfig: {
|
||||
responseMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract webhook path from node parameters
|
||||
*/
|
||||
function extractWebhookPath(params: Record<string, unknown>, nodeId: string): string {
|
||||
// Check for explicit path parameter
|
||||
if (typeof params.path === 'string' && params.path) {
|
||||
return params.path;
|
||||
}
|
||||
|
||||
// Check for httpMethod specific path
|
||||
if (typeof params.httpMethod === 'string') {
|
||||
const methodPath = params[`path_${params.httpMethod.toLowerCase()}`];
|
||||
if (typeof methodPath === 'string' && methodPath) {
|
||||
return methodPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: use node ID as path (n8n default behavior)
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTTP method from webhook parameters
|
||||
*/
|
||||
function extractHttpMethod(params: Record<string, unknown>): string {
|
||||
if (typeof params.httpMethod === 'string') {
|
||||
return params.httpMethod.toUpperCase();
|
||||
}
|
||||
return 'POST'; // Default to POST
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form field names from form trigger parameters
|
||||
*/
|
||||
function extractFormFields(params: Record<string, unknown>): string[] {
|
||||
const fields: string[] = [];
|
||||
|
||||
// Check for formFields parameter (common pattern)
|
||||
if (Array.isArray(params.formFields)) {
|
||||
for (const field of params.formFields) {
|
||||
if (field && typeof field.fieldLabel === 'string') {
|
||||
fields.push(field.fieldLabel);
|
||||
} else if (field && typeof field.fieldName === 'string') {
|
||||
fields.push(field.fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for fields in options
|
||||
const options = params.options as Record<string, unknown> | undefined;
|
||||
if (options && Array.isArray(options.formFields)) {
|
||||
for (const field of options.formFields) {
|
||||
if (field && typeof field.fieldLabel === 'string') {
|
||||
fields.push(field.fieldLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the trigger URL based on detected trigger and n8n base URL
|
||||
*
|
||||
* @param baseUrl - n8n instance base URL (e.g., https://n8n.example.com)
|
||||
* @param trigger - Detected trigger information
|
||||
* @param mode - 'production' uses /webhook/, 'test' uses /webhook-test/
|
||||
*/
|
||||
export function buildTriggerUrl(
|
||||
baseUrl: string,
|
||||
trigger: DetectedTrigger,
|
||||
mode: 'production' | 'test' = 'production'
|
||||
): string {
|
||||
const cleanBaseUrl = baseUrl.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'webhook':
|
||||
case 'chat': {
|
||||
const prefix = mode === 'test' ? 'webhook-test' : 'webhook';
|
||||
const path = trigger.webhookPath || trigger.node.id;
|
||||
return `${cleanBaseUrl}/${prefix}/${path}`;
|
||||
}
|
||||
|
||||
case 'form': {
|
||||
// Form triggers use /form/<workflowId> endpoint
|
||||
const prefix = mode === 'test' ? 'form-test' : 'form';
|
||||
return `${cleanBaseUrl}/${prefix}/${trigger.node.id}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Cannot build URL for trigger type: ${trigger.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of the detected trigger
|
||||
*/
|
||||
export function describeTrigger(trigger: DetectedTrigger): string {
|
||||
switch (trigger.type) {
|
||||
case 'webhook':
|
||||
return `Webhook trigger (${trigger.httpMethod || 'POST'} /${trigger.webhookPath || trigger.node.id})`;
|
||||
|
||||
case 'form':
|
||||
const fieldCount = trigger.formFields?.length || 0;
|
||||
return `Form trigger (${fieldCount} fields)`;
|
||||
|
||||
case 'chat':
|
||||
return `Chat trigger (${trigger.chatConfig?.responseMode || 'lastNode'} mode)`;
|
||||
|
||||
default:
|
||||
return 'Unknown trigger';
|
||||
}
|
||||
}
|
||||
118
src/triggers/trigger-registry.ts
Normal file
118
src/triggers/trigger-registry.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Trigger Registry - central registry for trigger handlers
|
||||
*
|
||||
* Uses the plugin pattern for extensibility:
|
||||
* - Register handlers at startup
|
||||
* - Get handler by trigger type
|
||||
* - List all registered types
|
||||
*/
|
||||
|
||||
import { N8nApiClient } from '../services/n8n-api-client';
|
||||
import { InstanceContext } from '../types/instance-context';
|
||||
import { TriggerType } from './types';
|
||||
import { BaseTriggerHandler, TriggerHandlerConstructor } from './handlers/base-handler';
|
||||
|
||||
/**
|
||||
* Central registry for trigger handlers
|
||||
*/
|
||||
export class TriggerRegistry {
|
||||
private static handlers: Map<TriggerType, TriggerHandlerConstructor> = new Map();
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* Register a trigger handler
|
||||
*
|
||||
* @param type - The trigger type this handler supports
|
||||
* @param HandlerClass - The handler class constructor
|
||||
*/
|
||||
static register(type: TriggerType, HandlerClass: TriggerHandlerConstructor): void {
|
||||
this.handlers.set(type, HandlerClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a handler instance for a trigger type
|
||||
*
|
||||
* @param type - The trigger type
|
||||
* @param client - n8n API client
|
||||
* @param context - Optional instance context
|
||||
* @returns Handler instance or undefined if not registered
|
||||
*/
|
||||
static getHandler(
|
||||
type: TriggerType,
|
||||
client: N8nApiClient,
|
||||
context?: InstanceContext
|
||||
): BaseTriggerHandler | undefined {
|
||||
const HandlerClass = this.handlers.get(type);
|
||||
if (!HandlerClass) {
|
||||
return undefined;
|
||||
}
|
||||
return new HandlerClass(client, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a trigger type has a registered handler
|
||||
*/
|
||||
static hasHandler(type: TriggerType): boolean {
|
||||
return this.handlers.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered trigger types
|
||||
*/
|
||||
static getRegisteredTypes(): TriggerType[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered handlers (useful for testing)
|
||||
*/
|
||||
static clear(): void {
|
||||
this.handlers.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if registry is initialized
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark registry as initialized
|
||||
*/
|
||||
static markInitialized(): void {
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry with all handlers
|
||||
* Called once at startup
|
||||
*/
|
||||
export async function initializeTriggerRegistry(): Promise<void> {
|
||||
if (TriggerRegistry.isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Import handlers dynamically to avoid circular dependencies
|
||||
const { WebhookHandler } = await import('./handlers/webhook-handler');
|
||||
const { FormHandler } = await import('./handlers/form-handler');
|
||||
const { ChatHandler } = await import('./handlers/chat-handler');
|
||||
|
||||
// Register all handlers
|
||||
TriggerRegistry.register('webhook', WebhookHandler);
|
||||
TriggerRegistry.register('form', FormHandler);
|
||||
TriggerRegistry.register('chat', ChatHandler);
|
||||
|
||||
TriggerRegistry.markInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure registry is initialized (lazy initialization)
|
||||
*/
|
||||
export async function ensureRegistryInitialized(): Promise<void> {
|
||||
if (!TriggerRegistry.isInitialized()) {
|
||||
await initializeTriggerRegistry();
|
||||
}
|
||||
}
|
||||
137
src/triggers/types.ts
Normal file
137
src/triggers/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Trigger system types for n8n_test_workflow tool
|
||||
*
|
||||
* Supports 3 trigger categories (all input-capable):
|
||||
* - webhook: AI can pass HTTP body/headers/params
|
||||
* - form: AI can pass form field values
|
||||
* - chat: AI can pass message + sessionId
|
||||
*
|
||||
* Note: Direct workflow execution via API is not supported by n8n's public API.
|
||||
* Workflows must have webhook/form/chat triggers to be executable externally.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../types/n8n-api';
|
||||
|
||||
/**
|
||||
* Supported trigger types (all input-capable)
|
||||
*/
|
||||
export type TriggerType = 'webhook' | 'form' | 'chat';
|
||||
|
||||
/**
|
||||
* Base input for all trigger handlers
|
||||
*/
|
||||
export interface BaseTriggerInput {
|
||||
workflowId: string;
|
||||
triggerType?: TriggerType;
|
||||
data?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
waitForResponse?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-specific input
|
||||
*/
|
||||
export interface WebhookTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'webhook';
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-specific input
|
||||
*/
|
||||
export interface FormTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'form';
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-specific input (sync mode only)
|
||||
*/
|
||||
export interface ChatTriggerInput extends BaseTriggerInput {
|
||||
triggerType: 'chat';
|
||||
message: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all trigger inputs
|
||||
*/
|
||||
export type TriggerInput =
|
||||
| WebhookTriggerInput
|
||||
| FormTriggerInput
|
||||
| ChatTriggerInput;
|
||||
|
||||
/**
|
||||
* Unified response from all trigger handlers
|
||||
*/
|
||||
export interface TriggerResponse {
|
||||
success: boolean;
|
||||
triggerType: TriggerType;
|
||||
workflowId: string;
|
||||
executionId?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
metadata: {
|
||||
duration: number;
|
||||
webhookPath?: string;
|
||||
sessionId?: string;
|
||||
httpMethod?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler capability flags
|
||||
*/
|
||||
export interface TriggerHandlerCapabilities {
|
||||
/** Whether workflow must be active for this trigger */
|
||||
requiresActiveWorkflow: boolean;
|
||||
/** Supported HTTP methods (for webhook) */
|
||||
supportedMethods?: string[];
|
||||
/** Whether this handler can pass input data to workflow */
|
||||
canPassInputData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detected trigger information from workflow analysis
|
||||
*/
|
||||
export interface DetectedTrigger {
|
||||
type: TriggerType;
|
||||
node: WorkflowNode;
|
||||
webhookPath?: string;
|
||||
httpMethod?: string;
|
||||
formFields?: string[];
|
||||
chatConfig?: {
|
||||
responseMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of trigger detection
|
||||
*/
|
||||
export interface TriggerDetectionResult {
|
||||
detected: boolean;
|
||||
trigger?: DetectedTrigger;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for the MCP tool (before trigger type detection)
|
||||
*/
|
||||
export interface TestWorkflowInput {
|
||||
workflowId: string;
|
||||
triggerType?: TriggerType;
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
webhookPath?: string;
|
||||
message?: string;
|
||||
sessionId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
waitForResponse?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user