feat: integrate n8n management tools from n8n-manager-for-ai-agents (v2.6.0)
- Added 14 n8n management tools for workflow CRUD and execution management - Integrated n8n API client with full error handling and validation - Added conditional tool registration (only when N8N_API_URL configured) - Complete workflow lifecycle: discover → build → validate → deploy → execute - Updated documentation and added integration tests - Maintains backward compatibility - existing functionality unchanged 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
390
src/services/n8n-api-client.ts
Normal file
390
src/services/n8n-api-client.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
Workflow,
|
||||
WorkflowListParams,
|
||||
WorkflowListResponse,
|
||||
Execution,
|
||||
ExecutionListParams,
|
||||
ExecutionListResponse,
|
||||
Credential,
|
||||
CredentialListParams,
|
||||
CredentialListResponse,
|
||||
Tag,
|
||||
TagListParams,
|
||||
TagListResponse,
|
||||
HealthCheckResponse,
|
||||
Variable,
|
||||
WebhookRequest,
|
||||
WorkflowExport,
|
||||
WorkflowImport,
|
||||
SourceControlStatus,
|
||||
SourceControlPullResult,
|
||||
SourceControlPushResult,
|
||||
} from '../types/n8n-api';
|
||||
import { handleN8nApiError, logN8nError } from '../utils/n8n-errors';
|
||||
import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';
|
||||
|
||||
export interface N8nApiClientConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeout?: number;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export class N8nApiClient {
|
||||
private client: AxiosInstance;
|
||||
private maxRetries: number;
|
||||
|
||||
constructor(config: N8nApiClientConfig) {
|
||||
const { baseUrl, apiKey, timeout = 30000, maxRetries = 3 } = config;
|
||||
|
||||
this.maxRetries = maxRetries;
|
||||
|
||||
// Ensure baseUrl ends with /api/v1
|
||||
const apiUrl = baseUrl.endsWith('/api/v1')
|
||||
? baseUrl
|
||||
: `${baseUrl.replace(/\/$/, '')}/api/v1`;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: apiUrl,
|
||||
timeout,
|
||||
headers: {
|
||||
'X-N8N-API-KEY': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for logging
|
||||
this.client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, {
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error: unknown) => {
|
||||
logger.error('n8n API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for logging
|
||||
this.client.interceptors.response.use(
|
||||
(response: any) => {
|
||||
logger.debug(`n8n API Response: ${response.status} ${response.config.url}`);
|
||||
return response;
|
||||
},
|
||||
(error: unknown) => {
|
||||
const n8nError = handleN8nApiError(error);
|
||||
logN8nError(n8nError, 'n8n API Response');
|
||||
return Promise.reject(n8nError);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Health check to verify API connectivity
|
||||
async healthCheck(): Promise<HealthCheckResponse> {
|
||||
try {
|
||||
// First try the health endpoint
|
||||
const response = await this.client.get('/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If health endpoint doesn't exist, try listing workflows with limit 1
|
||||
// This is a fallback for older n8n versions
|
||||
try {
|
||||
await this.client.get('/workflows', { params: { limit: 1 } });
|
||||
return {
|
||||
status: 'ok',
|
||||
features: {} // We can't determine features without proper health endpoint
|
||||
};
|
||||
} catch (fallbackError) {
|
||||
throw handleN8nApiError(fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow Management
|
||||
async createWorkflow(workflow: Partial<Workflow>): Promise<Workflow> {
|
||||
try {
|
||||
const cleanedWorkflow = cleanWorkflowForCreate(workflow);
|
||||
const response = await this.client.post('/workflows', cleanedWorkflow);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getWorkflow(id: string): Promise<Workflow> {
|
||||
try {
|
||||
const response = await this.client.get(`/workflows/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateWorkflow(id: string, workflow: Partial<Workflow>): Promise<Workflow> {
|
||||
try {
|
||||
// First, try PUT method (newer n8n versions)
|
||||
const cleanedWorkflow = cleanWorkflowForUpdate(workflow as Workflow);
|
||||
try {
|
||||
const response = await this.client.put(`/workflows/${id}`, cleanedWorkflow);
|
||||
return response.data;
|
||||
} catch (putError: any) {
|
||||
// If PUT fails with 405 (Method Not Allowed), try PATCH
|
||||
if (putError.response?.status === 405) {
|
||||
logger.debug('PUT method not supported, falling back to PATCH');
|
||||
const response = await this.client.patch(`/workflows/${id}`, cleanedWorkflow);
|
||||
return response.data;
|
||||
}
|
||||
throw putError;
|
||||
}
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWorkflow(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/workflows/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listWorkflows(params: WorkflowListParams = {}): Promise<WorkflowListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/workflows', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Execution Management
|
||||
async getExecution(id: string, includeData = false): Promise<Execution> {
|
||||
try {
|
||||
const response = await this.client.get(`/executions/${id}`, {
|
||||
params: { includeData },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listExecutions(params: ExecutionListParams = {}): Promise<ExecutionListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/executions', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExecution(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/executions/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook Execution
|
||||
async triggerWebhook(request: WebhookRequest): Promise<any> {
|
||||
try {
|
||||
const { webhookUrl, httpMethod, data, headers, waitForResponse = true } = request;
|
||||
|
||||
// Extract path from webhook URL
|
||||
const url = new URL(webhookUrl);
|
||||
const webhookPath = url.pathname;
|
||||
|
||||
// Make request directly to webhook endpoint
|
||||
const config: AxiosRequestConfig = {
|
||||
method: httpMethod,
|
||||
url: webhookPath,
|
||||
headers: {
|
||||
...headers,
|
||||
// Don't override API key header for webhook endpoints
|
||||
'X-N8N-API-KEY': undefined,
|
||||
},
|
||||
data: httpMethod !== 'GET' ? data : undefined,
|
||||
params: httpMethod === 'GET' ? data : undefined,
|
||||
// Webhooks might take longer
|
||||
timeout: waitForResponse ? 120000 : 30000,
|
||||
};
|
||||
|
||||
// Create a new axios instance for webhook requests to avoid API interceptors
|
||||
const webhookClient = axios.create({
|
||||
baseURL: new URL('/', webhookUrl).toString(),
|
||||
validateStatus: (status) => status < 500, // Don't throw on 4xx
|
||||
});
|
||||
|
||||
const response = await webhookClient.request(config);
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
};
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Credential Management
|
||||
async listCredentials(params: CredentialListParams = {}): Promise<CredentialListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/credentials', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getCredential(id: string): Promise<Credential> {
|
||||
try {
|
||||
const response = await this.client.get(`/credentials/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createCredential(credential: Partial<Credential>): Promise<Credential> {
|
||||
try {
|
||||
const response = await this.client.post('/credentials', credential);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateCredential(id: string, credential: Partial<Credential>): Promise<Credential> {
|
||||
try {
|
||||
const response = await this.client.patch(`/credentials/${id}`, credential);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCredential(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/credentials/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag Management
|
||||
async listTags(params: TagListParams = {}): Promise<TagListResponse> {
|
||||
try {
|
||||
const response = await this.client.get('/tags', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTag(tag: Partial<Tag>): Promise<Tag> {
|
||||
try {
|
||||
const response = await this.client.post('/tags', tag);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTag(id: string, tag: Partial<Tag>): Promise<Tag> {
|
||||
try {
|
||||
const response = await this.client.patch(`/tags/${id}`, tag);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTag(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/tags/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Source Control Management (Enterprise feature)
|
||||
async getSourceControlStatus(): Promise<SourceControlStatus> {
|
||||
try {
|
||||
const response = await this.client.get('/source-control/status');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async pullSourceControl(force = false): Promise<SourceControlPullResult> {
|
||||
try {
|
||||
const response = await this.client.post('/source-control/pull', { force });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async pushSourceControl(
|
||||
message: string,
|
||||
fileNames?: string[]
|
||||
): Promise<SourceControlPushResult> {
|
||||
try {
|
||||
const response = await this.client.post('/source-control/push', {
|
||||
message,
|
||||
fileNames,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Variable Management (via Source Control API)
|
||||
async getVariables(): Promise<Variable[]> {
|
||||
try {
|
||||
const response = await this.client.get('/variables');
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
// Variables might not be available in all n8n versions
|
||||
logger.warn('Variables API not available, returning empty array');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createVariable(variable: Partial<Variable>): Promise<Variable> {
|
||||
try {
|
||||
const response = await this.client.post('/variables', variable);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateVariable(id: string, variable: Partial<Variable>): Promise<Variable> {
|
||||
try {
|
||||
const response = await this.client.patch(`/variables/${id}`, variable);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteVariable(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/variables/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
src/services/n8n-validation.ts
Normal file
207
src/services/n8n-validation.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { z } from 'zod';
|
||||
import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api';
|
||||
|
||||
// Zod schemas for n8n API validation
|
||||
|
||||
export const workflowNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
typeVersion: z.number(),
|
||||
position: z.tuple([z.number(), z.number()]),
|
||||
parameters: z.record(z.unknown()),
|
||||
credentials: z.record(z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
notesInFlow: z.boolean().optional(),
|
||||
continueOnFail: z.boolean().optional(),
|
||||
retryOnFail: z.boolean().optional(),
|
||||
maxTries: z.number().optional(),
|
||||
waitBetweenTries: z.number().optional(),
|
||||
alwaysOutputData: z.boolean().optional(),
|
||||
executeOnce: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const workflowConnectionSchema = z.record(
|
||||
z.object({
|
||||
main: z.array(
|
||||
z.array(
|
||||
z.object({
|
||||
node: z.string(),
|
||||
type: z.string(),
|
||||
index: z.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export const workflowSettingsSchema = z.object({
|
||||
executionOrder: z.enum(['v0', 'v1']).default('v1'),
|
||||
timezone: z.string().optional(),
|
||||
saveDataErrorExecution: z.enum(['all', 'none']).default('all'),
|
||||
saveDataSuccessExecution: z.enum(['all', 'none']).default('all'),
|
||||
saveManualExecutions: z.boolean().default(true),
|
||||
saveExecutionProgress: z.boolean().default(true),
|
||||
executionTimeout: z.number().optional(),
|
||||
errorWorkflow: z.string().optional(),
|
||||
});
|
||||
|
||||
// Default settings for workflow creation
|
||||
export const defaultWorkflowSettings = {
|
||||
executionOrder: 'v1' as const,
|
||||
saveDataErrorExecution: 'all' as const,
|
||||
saveDataSuccessExecution: 'all' as const,
|
||||
saveManualExecutions: true,
|
||||
saveExecutionProgress: true,
|
||||
};
|
||||
|
||||
// Validation functions
|
||||
export function validateWorkflowNode(node: unknown): WorkflowNode {
|
||||
return workflowNodeSchema.parse(node);
|
||||
}
|
||||
|
||||
export function validateWorkflowConnections(connections: unknown): WorkflowConnection {
|
||||
return workflowConnectionSchema.parse(connections);
|
||||
}
|
||||
|
||||
export function validateWorkflowSettings(settings: unknown): z.infer<typeof workflowSettingsSchema> {
|
||||
return workflowSettingsSchema.parse(settings);
|
||||
}
|
||||
|
||||
// Clean workflow data for API operations
|
||||
export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Workflow> {
|
||||
const {
|
||||
// Remove read-only fields
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
versionId,
|
||||
meta,
|
||||
// Remove fields that cause API errors during creation
|
||||
active,
|
||||
tags,
|
||||
// Keep everything else
|
||||
...cleanedWorkflow
|
||||
} = workflow;
|
||||
|
||||
// Ensure settings are present with defaults
|
||||
if (!cleanedWorkflow.settings) {
|
||||
cleanedWorkflow.settings = defaultWorkflowSettings;
|
||||
}
|
||||
|
||||
return cleanedWorkflow;
|
||||
}
|
||||
|
||||
export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
const {
|
||||
// Remove read-only/computed fields
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
versionId,
|
||||
meta,
|
||||
staticData,
|
||||
// Remove fields that cause API errors
|
||||
pinData,
|
||||
tags,
|
||||
// Keep everything else
|
||||
...cleanedWorkflow
|
||||
} = workflow as any;
|
||||
|
||||
// Ensure settings are present
|
||||
if (!cleanedWorkflow.settings) {
|
||||
cleanedWorkflow.settings = defaultWorkflowSettings;
|
||||
}
|
||||
|
||||
return cleanedWorkflow;
|
||||
}
|
||||
|
||||
// Validate workflow structure
|
||||
export function validateWorkflowStructure(workflow: Partial<Workflow>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check required fields
|
||||
if (!workflow.name) {
|
||||
errors.push('Workflow name is required');
|
||||
}
|
||||
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
errors.push('Workflow must have at least one node');
|
||||
}
|
||||
|
||||
if (!workflow.connections) {
|
||||
errors.push('Workflow connections are required');
|
||||
}
|
||||
|
||||
// Validate nodes
|
||||
if (workflow.nodes) {
|
||||
workflow.nodes.forEach((node, index) => {
|
||||
try {
|
||||
validateWorkflowNode(node);
|
||||
} catch (error) {
|
||||
errors.push(`Invalid node at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate connections
|
||||
if (workflow.connections) {
|
||||
try {
|
||||
validateWorkflowConnections(workflow.connections);
|
||||
} catch (error) {
|
||||
errors.push(`Invalid connections: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that all connection references exist
|
||||
if (workflow.nodes && workflow.connections) {
|
||||
const nodeIds = new Set(workflow.nodes.map(node => node.id));
|
||||
|
||||
Object.entries(workflow.connections).forEach(([sourceId, connection]) => {
|
||||
if (!nodeIds.has(sourceId)) {
|
||||
errors.push(`Connection references non-existent source node: ${sourceId}`);
|
||||
}
|
||||
|
||||
connection.main.forEach((outputs, outputIndex) => {
|
||||
outputs.forEach((target, targetIndex) => {
|
||||
if (!nodeIds.has(target.node)) {
|
||||
errors.push(`Connection references non-existent target node: ${target.node} (from ${sourceId}[${outputIndex}][${targetIndex}])`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check if workflow has webhook trigger
|
||||
export function hasWebhookTrigger(workflow: Workflow): boolean {
|
||||
return workflow.nodes.some(node =>
|
||||
node.type === 'n8n-nodes-base.webhook' ||
|
||||
node.type === 'n8n-nodes-base.webhookTrigger'
|
||||
);
|
||||
}
|
||||
|
||||
// Get webhook URL from workflow
|
||||
export function getWebhookUrl(workflow: Workflow): string | null {
|
||||
const webhookNode = workflow.nodes.find(node =>
|
||||
node.type === 'n8n-nodes-base.webhook' ||
|
||||
node.type === 'n8n-nodes-base.webhookTrigger'
|
||||
);
|
||||
|
||||
if (!webhookNode || !webhookNode.parameters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for path parameter
|
||||
const path = webhookNode.parameters.path as string | undefined;
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: We can't construct the full URL without knowing the n8n instance URL
|
||||
// The caller will need to prepend the base URL
|
||||
return path;
|
||||
}
|
||||
Reference in New Issue
Block a user