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:
Romuald Członkowski
2025-12-01 15:55:14 +01:00
committed by GitHub
parent ddf9556759
commit 33690c5650
30 changed files with 4131 additions and 197 deletions

View 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,
};
}
}

View 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,
});
}
}
}

View 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,
});
}
}
}

View 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
View 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';

View 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';
}
}

View 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
View 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;
}