feat: Refactor codebase, improve types, attempt test fixes

This commit is contained in:
leonardsellem
2025-03-31 11:20:05 +02:00
parent d16ad72b22
commit ecd9133437
38 changed files with 829 additions and 811 deletions

View File

@@ -124,30 +124,29 @@ The webhook authentication is handled automatically using the `N8N_WEBHOOK_USERN
### Workflow Management
- `workflow_list`: List all workflows
- `workflow_get`: Get details of a specific workflow
- `workflow_create`: Create a new workflow
- `workflow_update`: Update an existing workflow
- `workflow_delete`: Delete a workflow
- `workflow_activate`: Activate a workflow
- `workflow_deactivate`: Deactivate a workflow
- `list_workflows`: List all workflows
- `get_workflow`: Get details of a specific workflow
- `create_workflow`: Create a new workflow
- `update_workflow`: Update an existing workflow
- `delete_workflow`: Delete a workflow
- `activate_workflow`: Activate a workflow
- `deactivate_workflow`: Deactivate a workflow
### Execution Management
- `execution_run`: Execute a workflow via the API
- `run_webhook`: Execute a workflow via a webhook
- `execution_get`: Get details of a specific execution
- `execution_list`: List executions for a workflow
- `execution_stop`: Stop a running execution
- `get_execution`: Get details of a specific execution
- `list_executions`: List executions for a workflow
- `delete_execution`: Delete a specific execution
## Resources
The server provides the following resources:
- `n8n://workflows/list`: List of all workflows
- `n8n://workflow/{id}`: Details of a specific workflow
- `n8n://executions/{workflowId}`: List of executions for a workflow
- `n8n://execution/{id}`: Details of a specific execution
- `n8n://workflows`: List of all workflows (static resource)
- `n8n://workflows/{id}`: Details of a specific workflow (dynamic resource template)
- `n8n://executions/{id}`: Details of a specific execution (dynamic resource template)
- `n8n://execution-stats`: Summary statistics of recent executions (static resource)
## Development

View File

@@ -1,9 +1,27 @@
module.exports = {
presets: [
module.exports = (api) => {
// Check if running in test environment (NODE_ENV is set to 'test' by run-tests.js)
const isTest = api.env('test');
const presets = [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
plugins: [
['@babel/plugin-transform-modules-commonjs']
]
];
const plugins = []; // Keep only one declaration
// Only add the CJS transform plugin if NOT in test environment
if (!isTest) {
plugins.push(['@babel/plugin-transform-modules-commonjs']);
}
// For Jest (test environment), ensure node_modules are not completely ignored
// if needed, but rely on transformIgnorePatterns in jest.config.cjs primarily.
// This is more of a fallback if transformIgnorePatterns isn't sufficient.
const ignore = isTest ? [] : [/node_modules/];
return {
presets,
plugins,
ignore, // Add ignore configuration back
};
};

View File

@@ -1,17 +1,18 @@
module.exports = {
// Use commonjs style export
preset: 'ts-jest',
preset: 'ts-jest/presets/default-esm', // Use ESM preset for ts-jest
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': 'babel-jest',
// Use ts-jest transformer with ESM support and point to tests tsconfig
'^.+\\.tsx?$': ['ts-jest', { useESM: true, tsconfig: 'tests/tsconfig.json' }],
},
// Allow src and test folders to resolve imports properly
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
extensionsToTreatAsEsm: ['.ts'], // Treat .ts as ESM
moduleNameMapper: {
// Recommended mapper for ts-jest ESM support: map extensionless paths to .js
'^(\\.{1,2}/.*)$': '$1.js',
},
// Handle the modelcontextprotocol SDK
// Handle the modelcontextprotocol SDK and other potential ESM dependencies
transformIgnorePatterns: [
"node_modules/(?!(@modelcontextprotocol)/)"
"/node_modules/(?!(@modelcontextprotocol/sdk|axios|another-esm-dep)/)" // Adjust as needed
],
collectCoverage: true,
coverageDirectory: 'coverage',

View File

@@ -9,9 +9,9 @@
"start": "node build/index.js",
"dev": "tsc -w",
"lint": "eslint --ext .ts src/",
"test": "node --experimental-vm-modules run-tests.js",
"test:watch": "node --experimental-vm-modules run-tests.js --watch",
"test:coverage": "node --experimental-vm-modules run-tests.js --coverage",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --config ./jest.config.cjs",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --config ./jest.config.cjs --watch",
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --config ./jest.config.cjs --coverage",
"prepare": "npm run build"
},
"bin": {

View File

@@ -4,9 +4,10 @@
* This module provides a client for interacting with the n8n API.
*/
import axios, { AxiosInstance } from 'axios';
import { EnvConfig } from '../config/environment.js';
import { handleAxiosError, N8nApiError } from '../errors/index.js';
import axios, { AxiosInstance, AxiosResponse } from 'axios'; // Import AxiosResponse
import { EnvConfig } from '../config/environment.js'; // Already has .js
import { handleAxiosError, N8nApiError } from '../errors/index.js'; // Already has .js
import { Workflow, Execution, ExecutionRunData } from '../types/index.js'; // Already has .js
/**
* n8n API Client class for making requests to the n8n API
@@ -86,9 +87,9 @@ export class N8nApiClient {
*
* @returns Array of workflow objects
*/
async getWorkflows(): Promise<any[]> {
async getWorkflows(): Promise<Workflow[]> { // Use specific type
try {
const response = await this.axiosInstance.get('/workflows');
const response: AxiosResponse<{ data: Workflow[] }> = await this.axiosInstance.get('/workflows');
return response.data.data || [];
} catch (error) {
throw handleAxiosError(error, 'Failed to fetch workflows');
@@ -101,9 +102,9 @@ export class N8nApiClient {
* @param id Workflow ID
* @returns Workflow object
*/
async getWorkflow(id: string): Promise<any> {
async getWorkflow(id: string): Promise<Workflow> { // Use specific type
try {
const response = await this.axiosInstance.get(`/workflows/${id}`);
const response: AxiosResponse<Workflow> = await this.axiosInstance.get(`/workflows/${id}`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to fetch workflow ${id}`);
@@ -115,9 +116,9 @@ export class N8nApiClient {
*
* @returns Array of execution objects
*/
async getExecutions(): Promise<any[]> {
async getExecutions(): Promise<Execution[]> { // Use specific type
try {
const response = await this.axiosInstance.get('/executions');
const response: AxiosResponse<{ data: Execution[] }> = await this.axiosInstance.get('/executions');
return response.data.data || [];
} catch (error) {
throw handleAxiosError(error, 'Failed to fetch executions');
@@ -130,9 +131,9 @@ export class N8nApiClient {
* @param id Execution ID
* @returns Execution object
*/
async getExecution(id: string): Promise<any> {
async getExecution(id: string): Promise<Execution> { // Use specific type
try {
const response = await this.axiosInstance.get(`/executions/${id}`);
const response: AxiosResponse<Execution> = await this.axiosInstance.get(`/executions/${id}`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to fetch execution ${id}`);
@@ -144,11 +145,12 @@ export class N8nApiClient {
*
* @param id Workflow ID
* @param data Optional data to pass to the workflow
* @returns Execution result
* @returns Execution result (structure might vary)
*/
async executeWorkflow(id: string, data?: Record<string, any>): Promise<any> {
async executeWorkflow(id: string, data?: Record<string, any>): Promise<ExecutionRunData> { // Use specific type
try {
const response = await this.axiosInstance.post(`/workflows/${id}/execute`, data || {});
// Assuming the response data directly matches ExecutionRunData or similar
const response: AxiosResponse<ExecutionRunData> = await this.axiosInstance.post(`/workflows/${id}/execute`, data || {});
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to execute workflow ${id}`);
@@ -161,7 +163,7 @@ export class N8nApiClient {
* @param workflow Workflow object to create
* @returns Created workflow
*/
async createWorkflow(workflow: Record<string, any>): Promise<any> {
async createWorkflow(workflow: Partial<Workflow>): Promise<Workflow> { // Use specific types
try {
// Make sure settings property is present
if (!workflow.settings) {
@@ -181,15 +183,16 @@ export class N8nApiClient {
delete workflowToCreate.id; // Remove id property if it exists
delete workflowToCreate.createdAt; // Remove createdAt property if it exists
delete workflowToCreate.updatedAt; // Remove updatedAt property if it exists
delete workflowToCreate.tags; // Remove tags property as it's read-only
delete workflowToCreate.tags; // Remove tags property if it exists and is read-only
// Log request for debugging
console.error('[DEBUG] Creating workflow with data:', JSON.stringify(workflowToCreate, null, 2));
// Removed debug log
// console.error('[DEBUG] Creating workflow with data:', JSON.stringify(workflowToCreate, null, 2));
const response = await this.axiosInstance.post('/workflows', workflowToCreate);
const response: AxiosResponse<Workflow> = await this.axiosInstance.post('/workflows', workflowToCreate);
return response.data;
} catch (error) {
console.error('[ERROR] Create workflow error:', error);
// Removed error log, handleAxiosError should suffice
// console.error('[ERROR] Create workflow error:', error);
throw handleAxiosError(error, 'Failed to create workflow');
}
}
@@ -201,9 +204,9 @@ export class N8nApiClient {
* @param workflow Updated workflow object
* @returns Updated workflow
*/
async updateWorkflow(id: string, workflow: Record<string, any>): Promise<any> {
async updateWorkflow(id: string, workflow: Partial<Workflow>): Promise<Workflow> { // Use specific types
try {
const response = await this.axiosInstance.put(`/workflows/${id}`, workflow);
const response: AxiosResponse<Workflow> = await this.axiosInstance.put(`/workflows/${id}`, workflow);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to update workflow ${id}`);
@@ -214,11 +217,12 @@ export class N8nApiClient {
* Delete a workflow
*
* @param id Workflow ID
* @returns Deleted workflow
* @returns Success indicator
*/
async deleteWorkflow(id: string): Promise<any> {
async deleteWorkflow(id: string): Promise<{ success: boolean }> { // Use specific type
try {
const response = await this.axiosInstance.delete(`/workflows/${id}`);
// Assuming API returns { success: true } or similar on successful delete
const response: AxiosResponse<{ success: boolean }> = await this.axiosInstance.delete(`/workflows/${id}`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to delete workflow ${id}`);
@@ -231,9 +235,10 @@ export class N8nApiClient {
* @param id Workflow ID
* @returns Activated workflow
*/
async activateWorkflow(id: string): Promise<any> {
async activateWorkflow(id: string): Promise<Workflow> { // Use specific type
try {
const response = await this.axiosInstance.post(`/workflows/${id}/activate`);
// Assuming API returns the updated workflow object
const response: AxiosResponse<Workflow> = await this.axiosInstance.post(`/workflows/${id}/activate`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to activate workflow ${id}`);
@@ -246,9 +251,10 @@ export class N8nApiClient {
* @param id Workflow ID
* @returns Deactivated workflow
*/
async deactivateWorkflow(id: string): Promise<any> {
async deactivateWorkflow(id: string): Promise<Workflow> { // Use specific type
try {
const response = await this.axiosInstance.post(`/workflows/${id}/deactivate`);
// Assuming API returns the updated workflow object
const response: AxiosResponse<Workflow> = await this.axiosInstance.post(`/workflows/${id}/deactivate`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to deactivate workflow ${id}`);
@@ -259,11 +265,12 @@ export class N8nApiClient {
* Delete an execution
*
* @param id Execution ID
* @returns Deleted execution or success message
* @returns Success indicator
*/
async deleteExecution(id: string): Promise<any> {
async deleteExecution(id: string): Promise<{ success: boolean }> { // Use specific type
try {
const response = await this.axiosInstance.delete(`/executions/${id}`);
// Assuming API returns { success: true } or similar on successful delete
const response: AxiosResponse<{ success: boolean }> = await this.axiosInstance.delete(`/executions/${id}`);
return response.data;
} catch (error) {
throw handleAxiosError(error, `Failed to delete execution ${id}`);

View File

@@ -1,152 +1,27 @@
/**
* n8n API Client Interface
* n8n API Client Export
*
* This module defines interfaces and types for the n8n API client.
* This module primarily exports the N8nApiClient class.
* The N8nApiService wrapper was removed as it provided little additional value.
*/
import { N8nApiClient } from './client.js';
import { EnvConfig } from '../config/environment.js';
import { Workflow, Execution } from '../types/index.js';
// Re-export the client class
export { N8nApiClient } from './client.js';
// Keep the factory function for consistency, but have it return N8nApiClient directly
/**
* n8n API service - provides functions for interacting with n8n API
*/
export class N8nApiService {
private client: N8nApiClient;
/**
* Create a new n8n API service
*
* @param config Environment configuration
*/
constructor(config: EnvConfig) {
this.client = new N8nApiClient(config);
}
/**
* Check connectivity to the n8n API
*/
async checkConnectivity(): Promise<void> {
return this.client.checkConnectivity();
}
/**
* Get all workflows from n8n
*
* @returns Array of workflow objects
*/
async getWorkflows(): Promise<Workflow[]> {
return this.client.getWorkflows();
}
/**
* Get a specific workflow by ID
*
* @param id Workflow ID
* @returns Workflow object
*/
async getWorkflow(id: string): Promise<Workflow> {
return this.client.getWorkflow(id);
}
/**
* Execute a workflow by ID
*
* @param id Workflow ID
* @param data Optional data to pass to the workflow
* @returns Execution result
*/
async executeWorkflow(id: string, data?: Record<string, any>): Promise<any> {
return this.client.executeWorkflow(id, data);
}
/**
* Create a new workflow
*
* @param workflow Workflow object to create
* @returns Created workflow
*/
async createWorkflow(workflow: Record<string, any>): Promise<Workflow> {
return this.client.createWorkflow(workflow);
}
/**
* Update an existing workflow
*
* @param id Workflow ID
* @param workflow Updated workflow object
* @returns Updated workflow
*/
async updateWorkflow(id: string, workflow: Record<string, any>): Promise<Workflow> {
return this.client.updateWorkflow(id, workflow);
}
/**
* Delete a workflow
*
* @param id Workflow ID
* @returns Deleted workflow or success message
*/
async deleteWorkflow(id: string): Promise<any> {
return this.client.deleteWorkflow(id);
}
/**
* Activate a workflow
*
* @param id Workflow ID
* @returns Activated workflow
*/
async activateWorkflow(id: string): Promise<Workflow> {
return this.client.activateWorkflow(id);
}
/**
* Deactivate a workflow
*
* @param id Workflow ID
* @returns Deactivated workflow
*/
async deactivateWorkflow(id: string): Promise<Workflow> {
return this.client.deactivateWorkflow(id);
}
/**
* Get all workflow executions
*
* @returns Array of execution objects
*/
async getExecutions(): Promise<Execution[]> {
return this.client.getExecutions();
}
/**
* Get a specific execution by ID
*
* @param id Execution ID
* @returns Execution object
*/
async getExecution(id: string): Promise<Execution> {
return this.client.getExecution(id);
}
/**
* Delete an execution
*
* @param id Execution ID
* @returns Deleted execution or success message
*/
async deleteExecution(id: string): Promise<any> {
return this.client.deleteExecution(id);
}
}
/**
* Create a new n8n API service
* Create a new n8n API client instance
*
* @param config Environment configuration
* @returns n8n API service
* @returns n8n API client instance
*/
export function createApiService(config: EnvConfig): N8nApiService {
return new N8nApiService(config);
export function createN8nApiClient(config: EnvConfig): N8nApiClient {
return new N8nApiClient(config);
}
// Export the type alias for convenience if needed elsewhere,
// though direct use of N8nApiClient is preferred.
export type N8nApiService = N8nApiClient;

View File

@@ -7,7 +7,7 @@
import dotenv from 'dotenv';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from '../errors/error-codes.js';
import { ErrorCode } from '../errors/error-codes.js'; // Already has .js
// Environment variable names
export const ENV_VARS = {

View File

@@ -13,14 +13,16 @@ import {
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { getEnvConfig } from './environment.js';
import { setupWorkflowTools } from '../tools/workflow/index.js';
import { setupExecutionTools } from '../tools/execution/index.js';
import { getEnvConfig, EnvConfig } from './environment.js'; // Import EnvConfig
import { setupWorkflowTools, ListWorkflowsHandler, GetWorkflowHandler, CreateWorkflowHandler, UpdateWorkflowHandler, DeleteWorkflowHandler, ActivateWorkflowHandler, DeactivateWorkflowHandler } from '../tools/workflow/index.js';
import { setupExecutionTools, ListExecutionsHandler, GetExecutionHandler, DeleteExecutionHandler, RunWebhookHandler } from '../tools/execution/index.js';
import { setupResourceHandlers } from '../resources/index.js';
import { createApiService } from '../api/n8n-client.js';
// Update imports to use N8nApiClient and its factory
import { createN8nApiClient, N8nApiClient } from '../api/n8n-client.js';
import { McpError, ErrorCode } from '../errors/index.js';
// Import types
import { ToolCallResult } from '../types/index.js';
import { ToolCallResult, BaseToolHandler } from '../types/index.js';
/**
* Configure and return an MCP server instance with all tools and resources
@@ -31,13 +33,13 @@ export async function configureServer(): Promise<Server> {
// Get validated environment configuration
const envConfig = getEnvConfig();
// Create n8n API service
const apiService = createApiService(envConfig);
// Create n8n API client instance
const apiClient = createN8nApiClient(envConfig); // Use new factory function name
// Verify n8n API connectivity
try {
console.error('Verifying n8n API connectivity...');
await apiService.checkConnectivity();
await apiClient.checkConnectivity(); // Use apiClient instance
console.error(`Successfully connected to n8n API at ${envConfig.n8nApiUrl}`);
} catch (error) {
console.error('ERROR: Failed to connect to n8n API:', error instanceof Error ? error.message : error);
@@ -58,10 +60,11 @@ export async function configureServer(): Promise<Server> {
}
);
// Set up all request handlers
// Set up all request handlers, passing the single apiClient instance where needed
setupToolListRequestHandler(server);
setupToolCallRequestHandler(server);
setupResourceHandlers(server, envConfig);
setupToolCallRequestHandler(server, apiClient); // Pass apiClient
// Pass envConfig to resource handlers as originally intended
setupResourceHandlers(server, envConfig);
return server;
}
@@ -87,72 +90,44 @@ function setupToolListRequestHandler(server: Server): void {
* Set up the tool call request handler for the server
*
* @param server MCP server instance
* @param apiClient The shared N8nApiClient instance
*/
function setupToolCallRequestHandler(server: Server): void {
// Update function signature to accept N8nApiClient
function setupToolCallRequestHandler(server: Server, apiClient: N8nApiClient): void {
// Map tool names to their handler classes - Update constructor signature type
// The constructor now expects N8nApiClient (which is aliased as N8nApiService)
const toolHandlerMap: Record<string, new (apiClient: N8nApiClient) => BaseToolHandler> = {
'list_workflows': ListWorkflowsHandler,
'get_workflow': GetWorkflowHandler,
'create_workflow': CreateWorkflowHandler,
'update_workflow': UpdateWorkflowHandler,
'delete_workflow': DeleteWorkflowHandler,
'activate_workflow': ActivateWorkflowHandler,
'deactivate_workflow': DeactivateWorkflowHandler,
'list_executions': ListExecutionsHandler,
'get_execution': GetExecutionHandler,
'delete_execution': DeleteExecutionHandler,
'run_webhook': RunWebhookHandler,
// Add other tools here
};
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
const args = request.params.arguments || {};
let result: ToolCallResult;
try {
// Import handlers
const {
ListWorkflowsHandler,
GetWorkflowHandler,
CreateWorkflowHandler,
UpdateWorkflowHandler,
DeleteWorkflowHandler,
ActivateWorkflowHandler,
DeactivateWorkflowHandler
} = await import('../tools/workflow/index.js');
const {
ListExecutionsHandler,
GetExecutionHandler,
DeleteExecutionHandler,
RunWebhookHandler
} = await import('../tools/execution/index.js');
// Route the tool call to the appropriate handler
if (toolName === 'list_workflows') {
const handler = new ListWorkflowsHandler();
result = await handler.execute(args);
} else if (toolName === 'get_workflow') {
const handler = new GetWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'create_workflow') {
const handler = new CreateWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'update_workflow') {
const handler = new UpdateWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'delete_workflow') {
const handler = new DeleteWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'activate_workflow') {
const handler = new ActivateWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'deactivate_workflow') {
const handler = new DeactivateWorkflowHandler();
result = await handler.execute(args);
} else if (toolName === 'list_executions') {
const handler = new ListExecutionsHandler();
result = await handler.execute(args);
} else if (toolName === 'get_execution') {
const handler = new GetExecutionHandler();
result = await handler.execute(args);
} else if (toolName === 'delete_execution') {
const handler = new DeleteExecutionHandler();
result = await handler.execute(args);
} else if (toolName === 'run_webhook') {
const handler = new RunWebhookHandler();
result = await handler.execute(args);
} else {
throw new Error(`Unknown tool: ${toolName}`);
const HandlerClass = toolHandlerMap[toolName];
if (!HandlerClass) {
throw new McpError(ErrorCode.NotImplemented, `Unknown tool: ${toolName}`); // Use NotImplemented
}
// Converting to MCP SDK expected format
// Pass the apiClient instance to the constructor
const handler = new HandlerClass(apiClient);
const result: ToolCallResult = await handler.execute(args);
// Return result in MCP SDK expected format
return {
content: result.content,
isError: result.isError,

View File

@@ -5,8 +5,8 @@
* for the n8n MCP Server.
*/
import { McpError as SdkMcpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from './error-codes.js';
import { McpError as SdkMcpError } from '@modelcontextprotocol/sdk/types.js'; // Add .js back
import { ErrorCode } from './error-codes.js'; // Add .js back
// Re-export McpError from SDK
export { McpError } from '@modelcontextprotocol/sdk/types.js';

View File

@@ -10,8 +10,8 @@ import {
ListResourceTemplatesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { EnvConfig } from '../config/environment.js';
import { createApiService } from '../api/n8n-client.js';
import { EnvConfig } from '../config/environment.js'; // Re-add EnvConfig import
import { createN8nApiClient, N8nApiClient } from '../api/n8n-client.js'; // Use correct factory import
import { McpError, ErrorCode } from '../errors/index.js';
// Import static resource handlers
@@ -46,12 +46,13 @@ import {
* @param server MCP server instance
* @param envConfig Environment configuration
*/
export function setupResourceHandlers(server: Server, envConfig: EnvConfig): void {
// Revert function signature to accept EnvConfig
export function setupResourceHandlers(server: Server, envConfig: EnvConfig): void {
// Set up static resources
setupStaticResources(server, envConfig);
setupStaticResources(server, envConfig); // Pass envConfig
// Set up dynamic resources
setupDynamicResources(server, envConfig);
setupDynamicResources(server, envConfig); // Pass envConfig
}
/**
@@ -60,8 +61,10 @@ export function setupResourceHandlers(server: Server, envConfig: EnvConfig): voi
* @param server MCP server instance
* @param envConfig Environment configuration
*/
function setupStaticResources(server: Server, envConfig: EnvConfig): void {
const apiService = createApiService(envConfig);
// Revert function signature to accept EnvConfig
function setupStaticResources(server: Server, envConfig: EnvConfig): void {
// Create apiClient internally for now
const apiClient = createN8nApiClient(envConfig); // Use correct factory function
server.setRequestHandler(ListResourcesRequestSchema, async () => {
// Return all available static resources
@@ -80,8 +83,10 @@ function setupStaticResources(server: Server, envConfig: EnvConfig): void {
* @param server MCP server instance
* @param envConfig Environment configuration
*/
function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
const apiService = createApiService(envConfig);
// Revert function signature to accept EnvConfig
function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
// Create apiClient internally for now
const apiClient = createN8nApiClient(envConfig); // Use correct factory function
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
// Return all available dynamic resource templates
@@ -100,7 +105,7 @@ function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
try {
// Handle static resources
if (uri === getWorkflowsResourceUri()) {
const content = await getWorkflowsResource(apiService);
const content = await getWorkflowsResource(apiClient); // Use apiClient instance
return {
contents: [
{
@@ -113,7 +118,7 @@ function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
}
if (uri === getExecutionStatsResourceUri()) {
const content = await getExecutionStatsResource(apiService);
const content = await getExecutionStatsResource(apiClient); // Use apiClient instance
return {
contents: [
{
@@ -128,7 +133,7 @@ function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
// Handle dynamic resources
const workflowId = extractWorkflowIdFromUri(uri);
if (workflowId) {
const content = await getWorkflowResource(apiService, workflowId);
const content = await getWorkflowResource(apiClient, workflowId); // Use apiClient instance
return {
contents: [
{
@@ -142,7 +147,7 @@ function setupDynamicResources(server: Server, envConfig: EnvConfig): void {
const executionId = extractExecutionIdFromUri(uri);
if (executionId) {
const content = await getExecutionResource(apiService, executionId);
const content = await getExecutionResource(apiClient, executionId); // Use apiClient instance
return {
contents: [
{

View File

@@ -4,16 +4,23 @@
* This module provides a base handler for execution-related tools.
*/
import { ToolCallResult } from '../../types/index.js';
import { N8nApiError } from '../../errors/index.js';
import { createApiService } from '../../api/n8n-client.js';
import { getEnvConfig } from '../../config/environment.js';
import { ToolCallResult, BaseToolHandler } from '../../types/index.js'; // Already has .js
import { N8nApiError } from '../../errors/index.js'; // Already has .js
import { N8nApiService } from '../../api/n8n-client.js'; // Already has .js
/**
* Base class for execution tool handlers
*/
export abstract class BaseExecutionToolHandler {
protected apiService = createApiService(getEnvConfig());
export abstract class BaseExecutionToolHandler implements BaseToolHandler { // Implement BaseToolHandler
protected apiService: N8nApiService; // Declare apiService property
/**
* Constructor to inject the API service
* @param apiService Instance of N8nApiService
*/
constructor(apiService: N8nApiService) {
this.apiService = apiService;
}
/**
* Validate and execute the tool

View File

@@ -9,6 +9,11 @@ import { ToolCallResult, ToolDefinition } from '../../types/index.js';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from '../../errors/error-codes.js';
// Define specific type for delete arguments
interface DeleteExecutionArgs {
executionId: string;
}
/**
* Handler for the delete_execution tool
*/
@@ -19,8 +24,8 @@ export class DeleteExecutionHandler extends BaseExecutionToolHandler {
* @param args Tool arguments (executionId)
* @returns Result of the deletion operation
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
return this.handleExecution(async () => {
async execute(args: DeleteExecutionArgs): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => { // Pass args to handler
// Validate required parameters
if (!args.executionId) {
throw new McpError(
@@ -39,7 +44,7 @@ export class DeleteExecutionHandler extends BaseExecutionToolHandler {
{ id: executionId, deleted: true },
`Successfully deleted execution with ID: ${executionId}`
);
}, args);
}, args); // Pass args to handleExecution
}
}

View File

@@ -10,6 +10,11 @@ import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from '../../errors/error-codes.js';
import { formatExecutionDetails } from '../../utils/execution-formatter.js';
// Define specific type for get arguments
interface GetExecutionArgs {
executionId: string;
}
/**
* Handler for the get_execution tool
*/
@@ -20,8 +25,8 @@ export class GetExecutionHandler extends BaseExecutionToolHandler {
* @param args Tool arguments (executionId)
* @returns Execution details
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
return this.handleExecution(async () => {
async execute(args: GetExecutionArgs): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => { // Pass args to handler
// Validate required parameters
if (!args.executionId) {
throw new McpError(
@@ -40,7 +45,7 @@ export class GetExecutionHandler extends BaseExecutionToolHandler {
formattedExecution,
`Execution Details for ID: ${args.executionId}`
);
}, args);
}, args); // Pass args to handleExecution
}
}

View File

@@ -5,9 +5,18 @@
*/
import { BaseExecutionToolHandler } from './base-handler.js';
import { ToolCallResult, ToolDefinition, Execution } from '../../types/index.js';
import { ToolCallResult, ToolDefinition, Execution } from '../../types/index.js'; // Import Execution type
import { formatExecutionSummary, summarizeExecutions } from '../../utils/execution-formatter.js';
// Define specific type for list arguments based on ToolDefinition
interface ListExecutionsArgs {
workflowId?: string;
status?: 'success' | 'error' | 'waiting' | 'canceled'; // Use specific statuses if known
limit?: number;
lastId?: string; // Note: n8n API might not support cursor pagination with lastId easily
includeSummary?: boolean;
}
/**
* Handler for the list_executions tool
*/
@@ -15,14 +24,16 @@ export class ListExecutionsHandler extends BaseExecutionToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments (workflowId, status, limit, lastId)
* @param args Tool arguments (workflowId, status, limit, lastId, includeSummary)
* @returns List of executions
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
return this.handleExecution(async () => {
const executions = await this.apiService.getExecutions();
async execute(args: ListExecutionsArgs): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => { // Pass args to handler
// Fetch all executions first (n8n API might require filtering via query params)
// TODO: Update apiService.getExecutions to accept filter parameters if possible
const executions: Execution[] = await this.apiService.getExecutions();
// Apply filters if provided
// Apply filters locally for now
let filteredExecutions = executions;
// Filter by workflow ID if provided
@@ -39,34 +50,43 @@ export class ListExecutionsHandler extends BaseExecutionToolHandler {
);
}
// Apply limit if provided
// TODO: Implement pagination using lastId if the API supports it.
// This usually requires sorting and finding the index, or specific API params.
// Apply limit if provided (after filtering and potential pagination)
const limit = args.limit && args.limit > 0 ? args.limit : filteredExecutions.length;
filteredExecutions = filteredExecutions.slice(0, limit);
// Ensure limit is applied correctly after potential pagination logic
filteredExecutions = filteredExecutions.slice(0, limit);
// Format the executions for display
const formattedExecutions = filteredExecutions.map((execution: Execution) =>
formatExecutionSummary(execution)
);
// Generate summary if requested
// Generate summary if requested (based on the initially fetched, unfiltered list)
let summary = undefined;
if (args.includeSummary) {
summary = summarizeExecutions(executions);
// Summarize based on the *original* list before filtering/limiting for accurate stats
summary = summarizeExecutions(executions);
}
// Prepare response data
const responseData = {
executions: formattedExecutions,
// Return the filtered and limited list
executions: formattedExecutions,
summary: summary,
total: formattedExecutions.length,
filtered: args.workflowId || args.status ? true : false
count: formattedExecutions.length, // Count of returned executions
// Indicate if filters were applied
filtersApplied: args.workflowId || args.status ? true : false,
// Note: totalAvailable might be misleading if pagination isn't fully implemented
totalAvailable: executions.length,
};
return this.formatSuccess(
responseData,
`Found ${formattedExecutions.length} execution(s)`
`Found ${formattedExecutions.length} execution(s)` + (responseData.filtersApplied ? ' matching filters.' : '.')
);
}, args);
}, args); // Pass args to handleExecution
}
}
@@ -78,7 +98,7 @@ export class ListExecutionsHandler extends BaseExecutionToolHandler {
export function getListExecutionsToolDefinition(): ToolDefinition {
return {
name: 'list_executions',
description: 'Retrieve a list of workflow executions from n8n',
description: 'Retrieve a list of workflow executions from n8n, with optional filtering.',
inputSchema: {
type: 'object',
properties: {
@@ -88,19 +108,23 @@ export function getListExecutionsToolDefinition(): ToolDefinition {
},
status: {
type: 'string',
description: 'Optional status to filter by (success, error, waiting, or canceled)',
description: 'Optional status to filter by (e.g., success, error, waiting)',
// Consider using enum if statuses are fixed:
// enum: ['success', 'error', 'waiting', 'canceled']
},
limit: {
type: 'number',
description: 'Maximum number of executions to return',
},
lastId: {
type: 'string',
description: 'ID of the last execution for pagination',
description: 'Maximum number of executions to return (default: all matching)',
},
// lastId is hard to implement without API support for cursor pagination
// lastId: {
// type: 'string',
// description: 'ID of the last execution for pagination (if supported)',
// },
includeSummary: {
type: 'boolean',
description: 'Include summary statistics about executions',
description: 'Include summary statistics about all executions (before filtering/limiting)',
default: false,
},
},
required: [],

View File

@@ -4,9 +4,9 @@
* This module provides a tool for running n8n workflows via webhooks.
*/
import axios from 'axios';
import axios, { AxiosRequestConfig } from 'axios'; // Import AxiosRequestConfig
import { z } from 'zod';
import { ToolCallResult } from '../../types/index.js';
import { ToolCallResult, ToolDefinition } from '../../types/index.js'; // Import ToolDefinition
import { BaseExecutionToolHandler } from './base-handler.js';
import { N8nApiError } from '../../errors/index.js';
import { getEnvConfig } from '../../config/environment.js';
@@ -33,7 +33,8 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
/**
* Tool definition for execution via webhook
*/
public static readonly inputSchema = runWebhookSchema;
// Note: Static properties on classes aren't directly usable for instance methods in TS
// The schema is used within the execute method instead.
/**
* Extract N8N base URL from N8N API URL by removing /api/v1
@@ -60,10 +61,11 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
* @param args Tool arguments
* @returns Tool call result
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
return this.handleExecution(async (args) => {
// Parse and validate arguments
const params = runWebhookSchema.parse(args);
async execute(args: RunWebhookParams): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => { // Pass args to handler
// Parse and validate arguments using the Zod schema
// This ensures args conforms to RunWebhookParams
const params = runWebhookSchema.parse(args);
// Get environment config for auth credentials
const config = getEnvConfig();
@@ -71,11 +73,13 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
try {
// Get the webhook URL with the proper prefix
const baseUrl = this.getN8nBaseUrl();
const webhookPath = `webhook/${params.workflowName}`;
// Ensure workflowName doesn't contain slashes that could break the URL path
const safeWorkflowName = params.workflowName.replace(/\//g, '');
const webhookPath = `webhook/${safeWorkflowName}`;
const webhookUrl = new URL(webhookPath, baseUrl).toString();
// Prepare request config with basic auth from environment
const requestConfig: any = {
const requestConfig: AxiosRequestConfig = { // Use AxiosRequestConfig type
headers: {
'Content-Type': 'application/json',
...(params.headers || {})
@@ -97,7 +101,7 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
return this.formatSuccess({
status: response.status,
statusText: response.statusText,
data: response.data
data: response.data // Assuming response.data is JSON-serializable
}, 'Webhook executed successfully');
} catch (error) {
// Handle error from the webhook request
@@ -106,20 +110,29 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
if (error.response) {
errorMessage = `Webhook execution failed with status ${error.response.status}: ${error.response.statusText}`;
if (error.response.data) {
return this.formatError(new N8nApiError(
`${errorMessage}\n\n${JSON.stringify(error.response.data, null, 2)}`,
error.response.status
));
// Attempt to stringify response data safely
let responseDataStr = '';
try {
responseDataStr = JSON.stringify(error.response.data, null, 2);
} catch (stringifyError) {
responseDataStr = '[Could not stringify response data]';
}
// Add explicit check for error.response before accessing status
const statusCode = error.response?.status || 500;
return this.formatError(new N8nApiError(
`${errorMessage}\n\n${responseDataStr}`,
statusCode
));
}
return this.formatError(new N8nApiError(errorMessage, error.response?.status || 500));
// Cast error.response to any before accessing status
return this.formatError(new N8nApiError(errorMessage, (error.response as any)?.status || 500));
}
throw error; // Re-throw non-axios errors for the handler to catch
// Re-throw non-axios errors for the base handler to catch
throw error;
}
}, args);
}, args); // Pass args to handleExecution
}
}
@@ -128,7 +141,7 @@ export class RunWebhookHandler extends BaseExecutionToolHandler {
*
* @returns Tool definition object
*/
export function getRunWebhookToolDefinition() {
export function getRunWebhookToolDefinition(): ToolDefinition { // Add return type
return {
name: 'run_webhook',
description: 'Execute a workflow via webhook with optional input data',
@@ -141,14 +154,17 @@ export function getRunWebhookToolDefinition() {
},
data: {
type: 'object',
description: 'Input data to pass to the webhook'
description: 'Input data (JSON object) to pass to the webhook',
// Indicate that properties can be anything for an object
additionalProperties: true,
},
headers: {
type: 'object',
description: 'Additional headers to send with the request'
description: 'Additional headers (key-value pairs) to send with the request',
additionalProperties: { type: 'string' },
}
},
required: ['workflowName']
}
};
}
}

View File

@@ -15,12 +15,12 @@ export class ActivateWorkflowHandler extends BaseWorkflowToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments containing workflowId
* @returns Activation confirmation
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
* @param args Tool arguments containing workflowId
* @returns Activated workflow details
*/
async execute(args: { workflowId: string }): Promise<ToolCallResult> { // Use specific type for args
return this.handleExecution(async (args) => {
const { workflowId } = args;
const { workflowId } = args; // Destructuring remains the same
if (!workflowId) {
throw new N8nApiError('Missing required parameter: workflowId');

View File

@@ -4,16 +4,23 @@
* This module provides a base handler for workflow-related tools.
*/
import { ToolCallResult } from '../../types/index.js';
import { N8nApiError } from '../../errors/index.js';
import { createApiService } from '../../api/n8n-client.js';
import { getEnvConfig } from '../../config/environment.js';
import { ToolCallResult, BaseToolHandler } from '../../types/index.js'; // Already has .js
import { N8nApiError } from '../../errors/index.js'; // Already has .js
import { N8nApiService } from '../../api/n8n-client.js'; // Already has .js
/**
* Base class for workflow tool handlers
*/
export abstract class BaseWorkflowToolHandler {
protected apiService = createApiService(getEnvConfig());
export abstract class BaseWorkflowToolHandler implements BaseToolHandler { // Implement BaseToolHandler
protected apiService: N8nApiService; // Declare apiService property
/**
* Constructor to inject the API service
* @param apiService Instance of N8nApiService
*/
constructor(apiService: N8nApiService) {
this.apiService = apiService;
}
/**
* Validate and execute the tool

View File

@@ -5,9 +5,18 @@
*/
import { BaseWorkflowToolHandler } from './base-handler.js';
import { ToolCallResult, ToolDefinition } from '../../types/index.js';
import { ToolCallResult, ToolDefinition, Workflow, N8nNode, N8nConnection } from '../../types/index.js'; // Import specific types
import { N8nApiError } from '../../errors/index.js';
// Define specific type for create arguments based on ToolDefinition
interface CreateWorkflowArgs {
name: string;
nodes?: N8nNode[];
connections?: N8nConnection;
active?: boolean;
tags?: string[];
}
/**
* Handler for the create_workflow tool
*/
@@ -18,43 +27,44 @@ export class CreateWorkflowHandler extends BaseWorkflowToolHandler {
* @param args Tool arguments containing workflow details
* @returns Created workflow information
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
async execute(args: CreateWorkflowArgs): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => {
const { name, nodes, connections, active, tags } = args;
if (!name) {
throw new N8nApiError('Missing required parameter: name');
// This check might be redundant if 'name' is required in schema, but good for safety
throw new N8nApiError('Missing required parameter: name');
}
// Validate nodes if provided
// Basic validation (more robust validation could use Zod or similar)
if (nodes && !Array.isArray(nodes)) {
throw new N8nApiError('Parameter "nodes" must be an array');
}
// Validate connections if provided
if (connections && typeof connections !== 'object') {
throw new N8nApiError('Parameter "connections" must be an object');
}
if (tags && !Array.isArray(tags)) {
throw new N8nApiError('Parameter "tags" must be an array of strings');
}
// Prepare workflow object
const workflowData: Record<string, any> = {
// Prepare workflow object using Partial<Workflow> for the API call
const workflowData: Partial<Workflow> = {
name,
active: active === true, // Default to false if not specified
active: active === true, // Default to false if not specified or undefined
nodes: nodes || [], // Default to empty array if not provided
connections: connections || {}, // Default to empty object if not provided
tags: tags || [], // Default to empty array if not provided
};
// Add optional fields if provided
if (nodes) workflowData.nodes = nodes;
if (connections) workflowData.connections = connections;
if (tags) workflowData.tags = tags;
// Create the workflow
const workflow = await this.apiService.createWorkflow(workflowData);
const createdWorkflow = await this.apiService.createWorkflow(workflowData);
// Return summary of the created workflow
return this.formatSuccess(
{
id: workflow.id,
name: workflow.name,
active: workflow.active
id: createdWorkflow.id,
name: createdWorkflow.name,
active: createdWorkflow.active
},
`Workflow created successfully`
);
@@ -80,18 +90,18 @@ export function getCreateWorkflowToolDefinition(): ToolDefinition {
},
nodes: {
type: 'array',
description: 'Array of node objects that define the workflow',
description: 'Array of node objects (N8nNode structure) defining the workflow',
items: {
type: 'object',
type: 'object', // Ideally, reference a detailed N8nNode schema here
},
},
connections: {
type: 'object',
description: 'Connection mappings between nodes',
description: 'Connection mappings between nodes (N8nConnection structure)',
},
active: {
type: 'boolean',
description: 'Whether the workflow should be active upon creation',
description: 'Whether the workflow should be active upon creation (defaults to false)',
},
tags: {
type: 'array',

View File

@@ -15,12 +15,12 @@ export class DeactivateWorkflowHandler extends BaseWorkflowToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments containing workflowId
* @returns Deactivation confirmation
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
* @param args Tool arguments containing workflowId
* @returns Deactivated workflow details
*/
async execute(args: { workflowId: string }): Promise<ToolCallResult> { // Use specific type for args
return this.handleExecution(async (args) => {
const { workflowId } = args;
const { workflowId } = args; // Destructuring remains the same
if (!workflowId) {
throw new N8nApiError('Missing required parameter: workflowId');

View File

@@ -15,12 +15,12 @@ export class DeleteWorkflowHandler extends BaseWorkflowToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments containing workflowId
* @returns Deletion confirmation
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
* @param args Tool arguments containing workflowId
* @returns Success message
*/
async execute(args: { workflowId: string }): Promise<ToolCallResult> { // Use specific type for args
return this.handleExecution(async (args) => {
const { workflowId } = args;
const { workflowId } = args; // Destructuring remains the same
if (!workflowId) {
throw new N8nApiError('Missing required parameter: workflowId');

View File

@@ -15,12 +15,12 @@ export class GetWorkflowHandler extends BaseWorkflowToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments containing workflowId
* @returns Workflow details
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
* @param args Tool arguments containing workflowId
* @returns Workflow details
*/
async execute(args: { workflowId: string }): Promise<ToolCallResult> { // Use specific type for args
return this.handleExecution(async (args) => {
const { workflowId } = args;
const { workflowId } = args; // Destructuring remains the same
if (!workflowId) {
throw new N8nApiError('Missing required parameter: workflowId');

View File

@@ -14,12 +14,17 @@ export class ListWorkflowsHandler extends BaseWorkflowToolHandler {
/**
* Execute the tool
*
* @param args Tool arguments
* @returns List of workflows
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
return this.handleExecution(async () => {
const workflows = await this.apiService.getWorkflows();
* @param args Tool arguments (expecting optional 'active' boolean)
* @returns List of workflows
*/
async execute(args: { active?: boolean }): Promise<ToolCallResult> { // Use specific type for args
return this.handleExecution(async (args) => { // Pass args to the handler
let workflows: Workflow[] = await this.apiService.getWorkflows(); // Add type annotation
// Apply filtering if the 'active' argument is provided
if (args && typeof args.active === 'boolean') {
workflows = workflows.filter((workflow: Workflow) => workflow.active === args.active);
}
// Format the workflows for display
const formattedWorkflows = workflows.map((workflow: Workflow) => ({
@@ -31,9 +36,9 @@ export class ListWorkflowsHandler extends BaseWorkflowToolHandler {
return this.formatSuccess(
formattedWorkflows,
`Found ${formattedWorkflows.length} workflow(s)`
`Found ${formattedWorkflows.length} workflow(s)` + (typeof args?.active === 'boolean' ? ` (filtered by active=${args.active})` : '')
);
}, args);
}, args); // Pass args to handleExecution
}
}

View File

@@ -5,9 +5,16 @@
*/
import { BaseWorkflowToolHandler } from './base-handler.js';
import { ToolCallResult, ToolDefinition } from '../../types/index.js';
import { ToolCallResult, ToolDefinition, Workflow, N8nNode, N8nConnection } from '../../types/index.js'; // Import specific types
import { N8nApiError } from '../../errors/index.js';
// Define specific type for update arguments
// Intersect with Partial<Workflow> to allow any workflow property update
// Requires workflowId to identify the workflow
interface UpdateWorkflowArgs extends Partial<Workflow> {
workflowId: string;
}
/**
* Handler for the update_workflow tool
*/
@@ -18,51 +25,73 @@ export class UpdateWorkflowHandler extends BaseWorkflowToolHandler {
* @param args Tool arguments containing workflow updates
* @returns Updated workflow information
*/
async execute(args: Record<string, any>): Promise<ToolCallResult> {
async execute(args: UpdateWorkflowArgs): Promise<ToolCallResult> { // Use specific args type
return this.handleExecution(async (args) => {
const { workflowId, name, nodes, connections, active, tags } = args;
const { workflowId, name, nodes, connections, active, tags, settings } = args; // Destructure known properties
if (!workflowId) {
throw new N8nApiError('Missing required parameter: workflowId');
}
// Validate nodes if provided
// Basic validation (more robust validation could use Zod or similar)
if (nodes && !Array.isArray(nodes)) {
throw new N8nApiError('Parameter "nodes" must be an array');
}
// Validate connections if provided
if (connections && typeof connections !== 'object') {
throw new N8nApiError('Parameter "connections" must be an object');
}
if (tags && !Array.isArray(tags)) {
throw new N8nApiError('Parameter "tags" must be an array of strings');
}
if (settings && typeof settings !== 'object') {
throw new N8nApiError('Parameter "settings" must be an object');
}
// Get the current workflow to update
const currentWorkflow = await this.apiService.getWorkflow(workflowId);
// Get the current workflow to compare changes (optional, but good for summary)
let currentWorkflow: Workflow | null = null;
try {
currentWorkflow = await this.apiService.getWorkflow(workflowId);
} catch (error) {
// Handle case where workflow to update doesn't exist
if (error instanceof N8nApiError && error.message.includes('not found')) { // Adjust error check as needed
throw new N8nApiError(`Workflow with ID "${workflowId}" not found.`);
}
throw error; // Re-throw other errors
}
// Prepare update object with changes
const workflowData: Record<string, any> = { ...currentWorkflow };
// Update fields if provided
if (name !== undefined) workflowData.name = name;
if (nodes !== undefined) workflowData.nodes = nodes;
if (connections !== undefined) workflowData.connections = connections;
if (active !== undefined) workflowData.active = active;
if (tags !== undefined) workflowData.tags = tags;
// Prepare update object with only the provided changes
const workflowUpdateData: Partial<Workflow> = {};
if (name !== undefined) workflowUpdateData.name = name;
if (nodes !== undefined) workflowUpdateData.nodes = nodes;
if (connections !== undefined) workflowUpdateData.connections = connections;
if (active !== undefined) workflowUpdateData.active = active;
if (tags !== undefined) workflowUpdateData.tags = tags;
if (settings !== undefined) workflowUpdateData.settings = settings;
// Add other updatable fields from Workflow interface if needed
// Check if there are any actual changes to send
if (Object.keys(workflowUpdateData).length === 0) {
return this.formatSuccess(
{ id: workflowId, name: currentWorkflow.name, active: currentWorkflow.active },
`No update parameters provided for workflow ${workflowId}. No changes made.`
);
}
// Update the workflow
const updatedWorkflow = await this.apiService.updateWorkflow(workflowId, workflowData);
const updatedWorkflow = await this.apiService.updateWorkflow(workflowId, workflowUpdateData);
// Build a summary of changes
// Build a summary of changes (optional)
const changesArray = [];
if (name !== undefined && name !== currentWorkflow.name) changesArray.push(`name: "${currentWorkflow.name}" → "${name}"`);
if (active !== undefined && active !== currentWorkflow.active) changesArray.push(`active: ${currentWorkflow.active}${active}`);
if (nodes !== undefined) changesArray.push('nodes updated');
if (connections !== undefined) changesArray.push('connections updated');
if (tags !== undefined) changesArray.push('tags updated');
if (settings !== undefined) changesArray.push('settings updated');
const changesSummary = changesArray.length > 0
? `Changes: ${changesArray.join(', ')}`
: 'No changes were made';
: 'No effective changes were made (values might be the same as current)';
return this.formatSuccess(
{
@@ -84,7 +113,7 @@ export class UpdateWorkflowHandler extends BaseWorkflowToolHandler {
export function getUpdateWorkflowToolDefinition(): ToolDefinition {
return {
name: 'update_workflow',
description: 'Update an existing workflow in n8n',
description: 'Update an existing workflow in n8n. Provide only the fields you want to change.',
inputSchema: {
type: 'object',
properties: {
@@ -98,14 +127,14 @@ export function getUpdateWorkflowToolDefinition(): ToolDefinition {
},
nodes: {
type: 'array',
description: 'Updated array of node objects that define the workflow',
description: 'Updated array of node objects (N8nNode structure) defining the workflow',
items: {
type: 'object',
type: 'object', // Ideally, reference a detailed N8nNode schema here
},
},
connections: {
type: 'object',
description: 'Updated connection mappings between nodes',
description: 'Updated connection mappings between nodes (N8nConnection structure)',
},
active: {
type: 'boolean',
@@ -118,8 +147,12 @@ export function getUpdateWorkflowToolDefinition(): ToolDefinition {
type: 'string',
},
},
settings: {
type: 'object',
description: 'Updated workflow settings (WorkflowSettings structure)',
},
},
required: ['workflowId'],
required: ['workflowId'], // Only ID is strictly required to identify the workflow
},
};
}

View File

@@ -25,31 +25,107 @@ export interface ToolCallResult {
isError?: boolean;
}
// Type for n8n workflow object
// Base interface for tool handlers
export interface BaseToolHandler {
execute(args: Record<string, any>): Promise<ToolCallResult>;
}
// --- n8n Specific Types ---
// Interface for n8n Node Parameters
export interface NodeParameter {
// Define common parameter properties if known, otherwise keep flexible
[key: string]: any;
}
// Interface for n8n Node
export interface N8nNode {
id: string;
name: string;
type: string;
typeVersion: number;
position: [number, number];
parameters: NodeParameter;
credentials?: Record<string, any>;
notes?: string;
disabled?: boolean;
[key: string]: any; // Allow other properties
}
// Interface for n8n Connection Data
export interface ConnectionData {
// Define specific properties if known
[key: string]: any;
}
// Interface for n8n Connection
export interface N8nConnection {
[outputNodeId: string]: {
[outputType: string]: Array<{
node: string; // Input node ID
type: string; // Input type
data?: ConnectionData;
}>;
};
}
// Interface for n8n Workflow Settings
export interface WorkflowSettings {
saveExecutionProgress?: boolean;
saveManualExecutions?: boolean;
saveDataErrorExecution?: string; // e.g., "all", "none"
saveDataSuccessExecution?: string; // e.g., "all", "none"
executionTimeout?: number;
timezone?: string;
errorWorkflow?: string;
[key: string]: any; // Allow other settings
}
// Enhanced Type for n8n workflow object
export interface Workflow {
id: string;
name: string;
active: boolean;
nodes: any[];
connections: any;
nodes: N8nNode[]; // Use specific Node type
connections: N8nConnection; // Use specific Connection type
createdAt: string;
updatedAt: string;
settings?: WorkflowSettings; // Use specific Settings type
staticData?: Record<string, any> | null;
tags?: string[]; // Assuming tags are strings
pinData?: Record<string, any>;
[key: string]: any; // Keep for flexibility if needed
}
// Interface for n8n Execution Error Details
export interface ExecutionError {
message?: string;
stack?: string;
[key: string]: any;
}
// Type for n8n execution object
// Interface for n8n Execution Run Data
export interface ExecutionRunData {
// Define specific properties if known structure exists
[key: string]: any;
}
// Enhanced Type for n8n execution object
export interface Execution {
id: string;
workflowId: string;
finished: boolean;
mode: string;
mode: string; // e.g., 'manual', 'webhook', 'trigger'
startedAt: string;
stoppedAt: string;
status: string;
data: {
resultData: {
runData: any;
stoppedAt: string | null; // Can be null if running
status: 'waiting' | 'running' | 'success' | 'error' | 'unknown'; // More specific statuses
data?: { // Make data optional
resultData?: { // Make resultData optional
runData?: ExecutionRunData; // Use specific RunData type
error?: ExecutionError; // Add error details
};
};
[key: string]: any;
workflowData?: Partial<Workflow>; // Workflow data might be partial
waitTill?: string | null;
[key: string]: any; // Keep for flexibility
}

View File

@@ -5,7 +5,7 @@
* in a consistent, user-friendly manner.
*/
import { Execution } from '../types/index.js';
import { Execution, ExecutionRunData, ExecutionError } from '../types/index.js'; // Import specific types
/**
* Format basic execution information for display
@@ -13,10 +13,11 @@ import { Execution } from '../types/index.js';
* @param execution Execution object
* @returns Formatted execution summary
*/
export function formatExecutionSummary(execution: Execution): Record<string, any> {
export function formatExecutionSummary(execution: Execution): Record<string, any> { // Keep return flexible for now
// Calculate duration
const startedAt = new Date(execution.startedAt);
const stoppedAt = execution.stoppedAt ? new Date(execution.stoppedAt) : new Date();
// Use current time if stoppedAt is null (execution still running)
const stoppedAt = execution.stoppedAt ? new Date(execution.stoppedAt) : new Date();
const durationMs = stoppedAt.getTime() - startedAt.getTime();
const durationSeconds = Math.round(durationMs / 1000);
@@ -40,49 +41,56 @@ export function formatExecutionSummary(execution: Execution): Record<string, any
* @param execution Execution object
* @returns Formatted execution details
*/
export function formatExecutionDetails(execution: Execution): Record<string, any> {
export function formatExecutionDetails(execution: Execution): Record<string, any> { // Keep return flexible
const summary = formatExecutionSummary(execution);
// Extract node results
const nodeResults: Record<string, any> = {};
if (execution.data?.resultData?.runData) {
for (const [nodeName, nodeData] of Object.entries(execution.data.resultData.runData)) {
const runData: ExecutionRunData | undefined = execution.data?.resultData?.runData;
if (runData) {
for (const [nodeName, nodeDataArray] of Object.entries(runData)) {
try {
// Get the last output
const lastOutput = Array.isArray(nodeData) && nodeData.length > 0
? nodeData[nodeData.length - 1]
// Get the last output object from the node's execution history array
const lastOutput = Array.isArray(nodeDataArray) && nodeDataArray.length > 0
? nodeDataArray[nodeDataArray.length - 1]
: null;
// Check if the last output has the expected structure
if (lastOutput && lastOutput.data && Array.isArray(lastOutput.data.main)) {
// Extract the output data
const outputData = lastOutput.data.main.length > 0
? lastOutput.data.main[0]
// Extract the output data items
const outputItems = lastOutput.data.main.length > 0
? lastOutput.data.main[0] // Assuming the first element contains the items array
: [];
nodeResults[nodeName] = {
status: lastOutput.status,
items: outputData.length,
data: outputData.slice(0, 3), // Limit to first 3 items to avoid overwhelming response
items: Array.isArray(outputItems) ? outputItems.length : 0, // Ensure items is an array
// Limit data preview to avoid overwhelming response
dataPreview: Array.isArray(outputItems) ? outputItems.slice(0, 3) : [],
};
} else {
nodeResults[nodeName] = { status: lastOutput?.status || 'unknown', items: 0, dataPreview: [] };
}
} catch (error) {
console.error(`Error parsing node output for ${nodeName}:`, error);
nodeResults[nodeName] = { error: 'Failed to parse node output' };
}
}
}
// Extract error details if present
const errorDetails: ExecutionError | undefined = execution.data?.resultData?.error;
// Add node results and error information to the summary
return {
...summary,
mode: execution.mode,
nodeResults: nodeResults,
// Include error information if present
error: execution.data?.resultData && 'error' in execution.data.resultData
? {
message: (execution.data.resultData as any).error?.message,
stack: (execution.data.resultData as any).error?.stack,
}
: undefined,
error: errorDetails ? { // Use the defined ExecutionError type
message: errorDetails.message,
stack: errorDetails.stack, // Include stack if available
} : undefined,
};
}
@@ -92,7 +100,7 @@ export function formatExecutionDetails(execution: Execution): Record<string, any
* @param status Execution status string
* @returns Status indicator emoji
*/
export function getStatusIndicator(status: string): string {
export function getStatusIndicator(status: Execution['status']): string { // Use specific status type
switch (status) {
case 'success':
return '✅'; // Success
@@ -100,10 +108,11 @@ export function getStatusIndicator(status: string): string {
return '❌'; // Error
case 'waiting':
return '⏳'; // Waiting
case 'canceled':
return '🛑'; // Canceled
// Add other potential statuses if known, e.g., 'canceled'
// case 'canceled':
// return '🛑';
default:
return '⏱️'; // In progress or unknown
return '⏱️'; // Running or unknown
}
}
@@ -114,7 +123,7 @@ export function getStatusIndicator(status: string): string {
* @param limit Maximum number of executions to include
* @returns Summary of execution results
*/
export function summarizeExecutions(executions: Execution[], limit: number = 10): Record<string, any> {
export function summarizeExecutions(executions: Execution[], limit: number = 10): Record<string, any> { // Keep return flexible
const limitedExecutions = executions.slice(0, limit);
// Group executions by status
@@ -132,7 +141,7 @@ export function summarizeExecutions(executions: Execution[], limit: number = 10)
return {
total: totalCount,
byStatus: Object.entries(byStatus).map(([status, count]) => ({
status: `${getStatusIndicator(status)} ${status}`,
status: `${getStatusIndicator(status as Execution['status'])} ${status}`, // Cast status
count,
percentage: totalCount > 0 ? Math.round((count / totalCount) * 100) : 0
})),

View File

@@ -5,7 +5,7 @@
* in a consistent, user-friendly manner for MCP resources.
*/
import { Workflow, Execution } from '../types/index.js';
import { Workflow, Execution, N8nNode } from '../types/index.js'; // Import N8nNode
import { formatExecutionSummary, summarizeExecutions } from './execution-formatter.js';
/**
@@ -14,7 +14,7 @@ import { formatExecutionSummary, summarizeExecutions } from './execution-formatt
* @param workflow Workflow object
* @returns Formatted workflow summary
*/
export function formatWorkflowSummary(workflow: Workflow): Record<string, any> {
export function formatWorkflowSummary(workflow: Workflow): Record<string, any> { // Keep return flexible
return {
id: workflow.id,
name: workflow.name,
@@ -31,21 +31,23 @@ export function formatWorkflowSummary(workflow: Workflow): Record<string, any> {
* @param workflow Workflow object
* @returns Formatted workflow details
*/
export function formatWorkflowDetails(workflow: Workflow): Record<string, any> {
export function formatWorkflowDetails(workflow: Workflow): Record<string, any> { // Keep return flexible
const summary = formatWorkflowSummary(workflow);
// Add additional details
return {
...summary,
nodes: workflow.nodes.map(node => ({
nodes: workflow.nodes.map((node: N8nNode) => ({ // Use N8nNode type
id: node.id,
name: node.name,
type: node.type,
position: node.position,
parameters: node.parameters,
parameters: node.parameters, // Keep parameters as is for now
disabled: node.disabled,
notes: node.notes,
})),
connections: workflow.connections,
staticData: workflow.staticData,
connections: workflow.connections, // Keep connections as is for now
staticData: workflow.staticData, // Keep staticData as is
settings: workflow.settings,
tags: workflow.tags,
// Exclude potentially sensitive or unuseful information

View File

@@ -2,6 +2,7 @@
* Axios mock utilities for n8n MCP Server tests
*/
import { jest } from '@jest/globals'; // Import jest
import { AxiosRequestConfig, AxiosResponse } from 'axios';
export interface MockResponse {
@@ -27,83 +28,84 @@ export const createMockAxiosResponse = (options: Partial<MockResponse> = {}): Ax
*/
export const createMockAxiosInstance = () => {
const mockRequests: Record<string, any[]> = {};
const mockResponses: Record<string, MockResponse[]> = {};
const mockResponses: Record<string, (MockResponse | Error)[]> = {}; // Allow Error type
const mockInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
get: jest.fn<any>(), // Add type hint for mock function
post: jest.fn<any>(), // Add type hint for mock function
put: jest.fn<any>(), // Add type hint for mock function
delete: jest.fn<any>(), // Add type hint for mock function
interceptors: {
request: {
use: jest.fn(),
use: jest.fn<any>(), // Add type hint for mock function
},
response: {
use: jest.fn(),
use: jest.fn<any>(), // Add type hint for mock function
},
},
defaults: {},
// Helper method to add mock response
addMockResponse(method: string, url: string, response: MockResponse | Error) {
if (!mockResponses[`${method}:${url}`]) {
mockResponses[`${method}:${url}`] = [];
}
if (response instanceof Error) {
mockResponses[`${method}:${url}`].push(response as any);
} else {
mockResponses[`${method}:${url}`].push(response);
const key = `${method}:${url}`;
if (!mockResponses[key]) {
mockResponses[key] = [];
}
mockResponses[key].push(response);
},
// Helper method to get request history
getRequestHistory(method: string, url: string) {
return mockRequests[`${method}:${url}`] || [];
const key = `${method}:${url}`;
return mockRequests[key] || [];
},
// Reset all mocks
reset() {
Object.keys(mockRequests).forEach(key => {
delete mockRequests[key];
});
Object.keys(mockResponses).forEach(key => {
delete mockResponses[key];
});
mockInstance.get.mockReset();
mockInstance.post.mockReset();
mockInstance.put.mockReset();
mockInstance.delete.mockReset();
mockInstance.interceptors.request.use.mockReset();
mockInstance.interceptors.response.use.mockReset();
}
};
// Setup method implementations
['get', 'post', 'put', 'delete'].forEach(method => {
mockInstance[method].mockImplementation(async (url: string, data?: any) => {
['get', 'post', 'put', 'delete'].forEach((method) => { // Remove explicit type annotation
(mockInstance as any)[method].mockImplementation(async (url: string, data?: any) => { // Keep cast for dynamic access
const requestKey = `${method}:${url}`;
if (!mockRequests[requestKey]) {
mockRequests[requestKey] = [];
}
mockRequests[requestKey].push(data);
if (mockResponses[requestKey] && mockResponses[requestKey].length > 0) {
const response = mockResponses[requestKey].shift();
const response = mockResponses[requestKey].shift(); // shift() can return undefined
if (response instanceof Error) {
throw response;
}
return createMockAxiosResponse(response);
if (response) { // Check if response is defined
return createMockAxiosResponse(response);
}
}
throw new Error(`No mock response defined for ${method.toUpperCase()} ${url}`);
});
});
return mockInstance;
};

View File

@@ -21,6 +21,7 @@ export const createMockWorkflow = (overrides: Partial<Workflow> = {}): Workflow
id: 'start',
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1, // Added missing property
parameters: {},
position: [100, 300],
},

View File

@@ -3,10 +3,17 @@
"compilerOptions": {
"types": ["jest", "node"],
"esModuleInterop": true,
"rootDir": ".."
// Remove rootDir override as base config now handles it
// "rootDir": "..",
"outDir": "../build/tests", // Optional: specify separate output for tests if needed
"composite": true // Keep composite for potential future use
},
"include": [
"**/*.ts",
"**/*.tsx"
"**/*.ts", // Keep existing test files
"../src/**/*.ts" // Add back source files referenced by tests
],
"exclude": [ // Add exclude for build output if needed
"../node_modules",
"../build"
]
}

View File

@@ -2,33 +2,40 @@
* N8nApiClient unit tests
*/
import '@jest/globals';
import axios from 'axios';
import { N8nApiClient } from '../../../src/api/client.js';
import { EnvConfig } from '../../../src/config/environment.js';
import { N8nApiError } from '../../../src/errors/index.js';
import { createMockAxiosInstance, createMockAxiosResponse } from '../../mocks/axios-mock.js';
import { mockApiResponses } from '../../mocks/n8n-fixtures.js';
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; // Explicit import
import axios, { AxiosInstance } from 'axios'; // Import AxiosInstance type
import { N8nApiClient } from '../../../src/api/client.js'; // Add .js
import { EnvConfig } from '../../../src/config/environment.js'; // Add .js
import { N8nApiError } from '../../../src/errors/index.js'; // Add .js
import { createMockAxiosInstance, createMockAxiosResponse } from '../../mocks/axios-mock.js'; // Add .js
import { mockApiResponses } from '../../mocks/n8n-fixtures.js'; // Add .js
// We will spy on axios.create instead of mocking the whole module
// jest.mock('axios');
// const mockedAxios = axios as jest.Mocked<typeof axios>;
// Mock axios
jest.mock('axios', () => ({
create: jest.fn(),
}));
describe('N8nApiClient', () => {
// Mock configuration
const mockConfig: EnvConfig = {
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
n8nWebhookUsername: 'test-user', // Added missing property
n8nWebhookPassword: 'test-password', // Added missing property
debug: false,
};
// Define a type for the mock axios instance based on axios-mock.ts
type MockAxiosInstance = ReturnType<typeof createMockAxiosInstance>;
// Mock axios instance
let mockAxios;
let mockAxios: MockAxiosInstance;
beforeEach(() => {
// Create the mock instance
mockAxios = createMockAxiosInstance();
(axios.create as jest.Mock).mockReturnValue(mockAxios);
// Spy on axios.create and mock its return value
jest.spyOn(axios, 'create').mockReturnValue(mockAxios as any);
});
afterEach(() => {
@@ -42,7 +49,7 @@ describe('N8nApiClient', () => {
new N8nApiClient(mockConfig);
// Assert
expect(axios.create).toHaveBeenCalledWith({
expect(axios.create).toHaveBeenCalledWith({ // Check the spy
baseURL: mockConfig.n8nApiUrl,
headers: {
'X-N8N-API-KEY': mockConfig.n8nApiKey,
@@ -80,6 +87,7 @@ describe('N8nApiClient', () => {
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
statusText: 'OK', // Added statusText
data: { data: [] },
});
@@ -92,6 +100,7 @@ describe('N8nApiClient', () => {
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 500,
statusText: 'Internal Server Error', // Added statusText
data: { message: 'Server error' },
});
@@ -116,6 +125,7 @@ describe('N8nApiClient', () => {
const mockWorkflows = mockApiResponses.workflows.list;
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
statusText: 'OK', // Added statusText
data: mockWorkflows,
});
@@ -132,6 +142,7 @@ describe('N8nApiClient', () => {
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
statusText: 'OK', // Added statusText
data: {},
});
@@ -160,6 +171,7 @@ describe('N8nApiClient', () => {
const mockWorkflow = mockApiResponses.workflows.single(workflowId);
mockAxios.addMockResponse('get', `/workflows/${workflowId}`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockWorkflow,
});
@@ -193,6 +205,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('post', `/workflows/${workflowId}/execute`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -214,6 +227,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('post', '/workflows', {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -236,6 +250,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('put', `/workflows/${workflowId}`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -257,6 +272,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('delete', `/workflows/${workflowId}`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -278,6 +294,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('post', `/workflows/${workflowId}/activate`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -299,6 +316,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('post', `/workflows/${workflowId}/deactivate`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});
@@ -318,6 +336,7 @@ describe('N8nApiClient', () => {
const mockExecutions = mockApiResponses.executions.list;
mockAxios.addMockResponse('get', '/executions', {
status: 200,
statusText: 'OK', // Added statusText
data: mockExecutions,
});
@@ -338,6 +357,7 @@ describe('N8nApiClient', () => {
const mockExecution = mockApiResponses.executions.single(executionId);
mockAxios.addMockResponse('get', `/executions/${executionId}`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockExecution,
});
@@ -359,6 +379,7 @@ describe('N8nApiClient', () => {
mockAxios.addMockResponse('delete', `/executions/${executionId}`, {
status: 200,
statusText: 'OK', // Added statusText
data: mockResponse,
});

View File

@@ -1,54 +0,0 @@
/**
* Simple HTTP client tests without complex dependencies
*/
import { describe, it, expect } from '@jest/globals';
// Create a simple HTTP client class to test
class SimpleHttpClient {
constructor(private baseUrl: string, private apiKey: string) {}
getBaseUrl(): string {
return this.baseUrl;
}
getApiKey(): string {
return this.apiKey;
}
buildAuthHeader(): Record<string, string> {
return {
'X-N8N-API-KEY': this.apiKey
};
}
formatUrl(path: string): string {
return `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`;
}
}
describe('SimpleHttpClient', () => {
it('should store baseUrl and apiKey properly', () => {
const baseUrl = 'https://n8n.example.com/api/v1';
const apiKey = 'test-api-key';
const client = new SimpleHttpClient(baseUrl, apiKey);
expect(client.getBaseUrl()).toBe(baseUrl);
expect(client.getApiKey()).toBe(apiKey);
});
it('should create proper auth headers', () => {
const client = new SimpleHttpClient('https://n8n.example.com/api/v1', 'test-api-key');
const headers = client.buildAuthHeader();
expect(headers).toEqual({ 'X-N8N-API-KEY': 'test-api-key' });
});
it('should format URLs correctly', () => {
const baseUrl = 'https://n8n.example.com/api/v1';
const client = new SimpleHttpClient(baseUrl, 'test-api-key');
expect(client.formatUrl('workflows')).toBe(`${baseUrl}/workflows`);
expect(client.formatUrl('/workflows')).toBe(`${baseUrl}/workflows`);
});
});

View File

@@ -3,10 +3,10 @@
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { getEnvConfig, loadEnvironmentVariables, ENV_VARS } from '../../../src/config/environment.js';
import { getEnvConfig, loadEnvironmentVariables, ENV_VARS } from '../../../src/config/environment.js'; // Add .js
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from '../../../src/errors/error-codes.js';
import { mockEnv } from '../../test-setup.js';
import { ErrorCode } from '../../../src/errors/error-codes.js'; // Add .js
import { mockEnv } from '../../test-setup.js'; // Add .js
describe('Environment Configuration', () => {
const originalEnv = process.env;
@@ -36,6 +36,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
// Execute
const config = getEnvConfig();
@@ -44,6 +46,8 @@ describe('Environment Configuration', () => {
expect(config).toEqual({
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
n8nWebhookUsername: 'test-user',
n8nWebhookPassword: 'test-pass',
debug: false,
});
});
@@ -52,6 +56,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
process.env[ENV_VARS.DEBUG] = 'true';
// Execute
@@ -65,6 +71,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
process.env[ENV_VARS.DEBUG] = 'TRUE';
// Execute
@@ -78,6 +86,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
process.env[ENV_VARS.DEBUG] = 'yes';
// Execute
@@ -117,6 +127,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'invalid-url';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
// Execute & Assert
expect(() => getEnvConfig()).toThrow(
@@ -131,6 +143,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'http://localhost:5678/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
// Execute
const config = getEnvConfig();
@@ -143,6 +157,8 @@ describe('Environment Configuration', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1/';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.N8N_WEBHOOK_USERNAME] = 'test-user'; // Add webhook user
process.env[ENV_VARS.N8N_WEBHOOK_PASSWORD] = 'test-pass'; // Add webhook pass
// Execute
const config = getEnvConfig();
@@ -155,14 +171,18 @@ describe('Environment Configuration', () => {
describe('with mockEnv helper', () => {
// Using the mockEnv helper from test-setup
mockEnv({
[ENV_VARS.N8N_API_URL]: 'https://n8n.example.com/api/v1',
[ENV_VARS.N8N_API_KEY]: 'test-api-key',
[ENV_VARS.N8N_API_URL]: 'https://mock.n8n.com/api/v1',
[ENV_VARS.N8N_API_KEY]: 'mock-api-key',
[ENV_VARS.N8N_WEBHOOK_USERNAME]: 'mock-user', // Add webhook user
[ENV_VARS.N8N_WEBHOOK_PASSWORD]: 'mock-pass', // Add webhook pass
});
it('should use the mocked environment variables', () => {
const config = getEnvConfig();
expect(config.n8nApiUrl).toBe('https://n8n.example.com/api/v1');
expect(config.n8nApiKey).toBe('test-api-key');
expect(config.n8nApiUrl).toBe('https://mock.n8n.com/api/v1');
expect(config.n8nApiKey).toBe('mock-api-key');
expect(config.n8nWebhookUsername).toBe('mock-user');
expect(config.n8nWebhookPassword).toBe('mock-pass');
});
});
});

View File

@@ -1,97 +0,0 @@
/**
* Simple environment configuration tests
*/
import { describe, it, expect } from '@jest/globals';
// Simple environment validation function to test
function validateEnvironment(env: Record<string, string | undefined>): {
n8nApiUrl: string;
n8nApiKey: string;
debug: boolean;
} {
// Check required variables
if (!env.N8N_API_URL) {
throw new Error('Missing required environment variable: N8N_API_URL');
}
if (!env.N8N_API_KEY) {
throw new Error('Missing required environment variable: N8N_API_KEY');
}
// Validate URL format
try {
new URL(env.N8N_API_URL);
} catch (error) {
throw new Error(`Invalid URL format for N8N_API_URL: ${env.N8N_API_URL}`);
}
// Return parsed config
return {
n8nApiUrl: env.N8N_API_URL,
n8nApiKey: env.N8N_API_KEY,
debug: env.DEBUG?.toLowerCase() === 'true'
};
}
describe('Environment Configuration', () => {
describe('validateEnvironment', () => {
it('should return a valid config when all required variables are present', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1',
N8N_API_KEY: 'test-api-key'
};
const config = validateEnvironment(env);
expect(config).toEqual({
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
debug: false
});
});
it('should set debug to true when DEBUG=true', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1',
N8N_API_KEY: 'test-api-key',
DEBUG: 'true'
};
const config = validateEnvironment(env);
expect(config.debug).toBe(true);
});
it('should throw an error when N8N_API_URL is missing', () => {
const env = {
N8N_API_KEY: 'test-api-key'
};
expect(() => validateEnvironment(env)).toThrow(
'Missing required environment variable: N8N_API_URL'
);
});
it('should throw an error when N8N_API_KEY is missing', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1'
};
expect(() => validateEnvironment(env)).toThrow(
'Missing required environment variable: N8N_API_KEY'
);
});
it('should throw an error when N8N_API_URL is not a valid URL', () => {
const env = {
N8N_API_URL: 'invalid-url',
N8N_API_KEY: 'test-api-key'
};
expect(() => validateEnvironment(env)).toThrow(
'Invalid URL format for N8N_API_URL: invalid-url'
);
});
});
});

View File

@@ -1,38 +1,38 @@
/**
* Simple test for URI Template functionality
* Tests for dynamic workflow resource URI functions
*/
import { describe, it, expect } from '@jest/globals';
// Simple functions to test without complex imports
function getWorkflowResourceTemplateUri() {
return 'n8n://workflows/{id}';
}
function extractWorkflowIdFromUri(uri: string): string | null {
const regex = /^n8n:\/\/workflows\/([^/]+)$/;
const match = uri.match(regex);
return match ? match[1] : null;
}
// Import the actual functions from the source file with .js extension
import {
getWorkflowResourceTemplateUri,
extractWorkflowIdFromUri
} from '../../../../src/resources/dynamic/workflow.js';
describe('Workflow Resource URI Functions', () => {
describe('getWorkflowResourceTemplateUri', () => {
it('should return the correct URI template', () => {
// Test the actual imported function
expect(getWorkflowResourceTemplateUri()).toBe('n8n://workflows/{id}');
});
});
describe('extractWorkflowIdFromUri', () => {
it('should extract workflow ID from valid URI', () => {
// Test the actual imported function
expect(extractWorkflowIdFromUri('n8n://workflows/123abc')).toBe('123abc');
expect(extractWorkflowIdFromUri('n8n://workflows/workflow-name-with-dashes')).toBe('workflow-name-with-dashes');
});
it('should return null for invalid URI formats', () => {
// Test the actual imported function
expect(extractWorkflowIdFromUri('n8n://workflows/')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflows')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflow/123')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflow/123')).toBeNull(); // Should fail based on regex
expect(extractWorkflowIdFromUri('invalid://workflows/123')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflows/123/extra')).toBeNull(); // Should fail based on regex
});
});
// TODO: Add tests for getWorkflowResource function (requires mocking apiService)
});

View File

@@ -0,0 +1,154 @@
/**
* ListWorkflowsHandler unit tests
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { ListWorkflowsHandler, getListWorkflowsToolDefinition } from '../../../../src/tools/workflow/list.js'; // Add .js back
import { createMockWorkflows } from '../../../mocks/n8n-fixtures.js'; // Add .js back
// Import BaseWorkflowToolHandler to check constructor call, but don't mock it directly
import { BaseWorkflowToolHandler } from '../../../../src/tools/workflow/base-handler.js'; // Add .js back
// Generate mock data
const mockWorkflows = createMockWorkflows();
// Mock dependencies of the BaseWorkflowToolHandler
const mockGetWorkflows = jest.fn(); // Define typed mock function variable
const mockApiService = {
getWorkflows: mockGetWorkflows,
// Add other methods used by BaseWorkflowToolHandler if necessary
};
const mockEnvConfig = {
/* mock necessary env config properties */
n8nApiUrl: 'http://mock-n8n.com',
n8nApiKey: 'mock-key',
n8nWebhookUsername: 'test-user', // Added webhook user
n8nWebhookPassword: 'test-pass', // Added webhook pass
};
// Add .js extension back to mock paths
jest.mock('../../../../src/config/environment.js', () => ({
getEnvConfig: jest.fn(() => mockEnvConfig),
}));
jest.mock('../../../../src/api/n8n-client.js', () => ({
createApiService: jest.fn(() => mockApiService),
}));
import { Workflow, ToolCallResult } from '../../../../src/types/index.js'; // Add .js back
describe('ListWorkflows Tool', () => {
let handler: ListWorkflowsHandler;
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Instantiate the handler, passing the mocked apiService (cast to any)
handler = new ListWorkflowsHandler(mockApiService as any);
// Check that dependencies were called by the base constructor
// Use the mocked functions directly now, casting the require result
const configMock = jest.requireMock('../../../../src/config/environment.js') as any; // Add .js back
const apiMock = jest.requireMock('../../../../src/api/n8n-client.js') as any; // Add .js back
expect(configMock.getEnvConfig).toHaveBeenCalledTimes(1);
expect(apiMock.createApiService).toHaveBeenCalledWith(mockEnvConfig);
});
describe('getListWorkflowsToolDefinition', () => {
it('should return the correct tool definition', () => {
// Execute
const definition = getListWorkflowsToolDefinition();
// Assert
expect(definition.name).toBe('list_workflows');
expect(definition.description).toBeTruthy();
expect(definition.inputSchema).toBeDefined();
expect(definition.inputSchema.properties).toHaveProperty('active');
expect(definition.inputSchema.required).toEqual([]);
});
});
describe('execute', () => {
it('should fetch all workflows and format them when no filter is provided', async () => {
// Arrange
// @ts-ignore - Suppress persistent TS2345 error
mockGetWorkflows.mockResolvedValue(mockWorkflows);
const expectedFormatted = mockWorkflows.map((wf: Workflow) => ({ // Add type annotation
id: wf.id,
name: wf.name,
active: wf.active,
updatedAt: wf.updatedAt,
}));
// Act
const result = await handler.execute({});
// Assert
expect(mockApiService.getWorkflows).toHaveBeenCalledTimes(1);
// Check the actual result, which uses the real base class formatters
expect(result.isError).toBe(false);
expect(result.content[0].text).toContain(`Found ${expectedFormatted.length} workflow(s)`);
expect(result.content[0].text).toContain(JSON.stringify(expectedFormatted, null, 2));
});
it('should filter workflows by active=true when provided', async () => {
// Arrange
// @ts-ignore - Suppress persistent TS2345 error
mockGetWorkflows.mockResolvedValue(mockWorkflows);
const activeWorkflows = mockWorkflows.filter(wf => wf.active === true);
const expectedFormatted = activeWorkflows.map((wf: Workflow) => ({ // Add type annotation
id: wf.id,
name: wf.name,
active: wf.active,
updatedAt: wf.updatedAt,
}));
// Act
const result = await handler.execute({ active: true });
// Assert
expect(mockApiService.getWorkflows).toHaveBeenCalledTimes(1);
expect(result.isError).toBe(false);
expect(result.content[0].text).toContain(`Found ${expectedFormatted.length} workflow(s) (filtered by active=true)`);
expect(result.content[0].text).toContain(JSON.stringify(expectedFormatted, null, 2));
});
it('should filter workflows by active=false when provided', async () => {
// Arrange
// @ts-ignore - Suppress persistent TS2345 error
mockGetWorkflows.mockResolvedValue(mockWorkflows);
const inactiveWorkflows = mockWorkflows.filter(wf => wf.active === false);
const expectedFormatted = inactiveWorkflows.map((wf: Workflow) => ({ // Add type annotation
id: wf.id,
name: wf.name,
active: wf.active,
updatedAt: wf.updatedAt,
}));
// Act
const result = await handler.execute({ active: false });
// Assert
expect(mockApiService.getWorkflows).toHaveBeenCalledTimes(1);
expect(result.isError).toBe(false);
expect(result.content[0].text).toContain(`Found ${expectedFormatted.length} workflow(s) (filtered by active=false)`);
expect(result.content[0].text).toContain(JSON.stringify(expectedFormatted, null, 2));
});
it('should handle errors during API call', async () => {
// Arrange
const apiError = new Error('API Failure');
// @ts-ignore - Suppress persistent TS2345 error
mockGetWorkflows.mockRejectedValue(apiError);
// Act
const result = await handler.execute({});
// Assert
expect(mockApiService.getWorkflows).toHaveBeenCalledTimes(1);
// Check the actual error result formatted by the real base class method
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Error executing workflow tool: API Failure');
});
});
});

View File

@@ -1,25 +0,0 @@
/**
* ListWorkflowsHandler unit tests
*/
import { describe, it, expect, jest } from '@jest/globals';
import { getListWorkflowsToolDefinition } from '../../../../src/tools/workflow/list.js';
import { mockApiResponses } from '../../../mocks/n8n-fixtures.js';
// Since this is an integration test, we'll test the definition directly
// rather than mocking the complex handler implementation
jest.mock('../../../../src/tools/workflow/base-handler.js');
describe('getListWorkflowsToolDefinition', () => {
it('should return the correct tool definition', () => {
// Execute
const definition = getListWorkflowsToolDefinition();
// Assert
expect(definition.name).toBe('list_workflows');
expect(definition.description).toBeTruthy();
expect(definition.inputSchema).toBeDefined();
expect(definition.inputSchema.properties).toHaveProperty('active');
expect(definition.inputSchema.required).toEqual([]);
});
});

View File

@@ -1,90 +0,0 @@
/**
* Simple workflow tool tests without complex dependencies
*/
import { describe, it, expect } from '@jest/globals';
// Mock workflow data
const mockWorkflows = [
{
id: '1234abc',
name: 'Test Workflow 1',
active: true,
createdAt: '2025-03-01T12:00:00.000Z',
updatedAt: '2025-03-02T14:30:00.000Z',
nodes: []
},
{
id: '5678def',
name: 'Test Workflow 2',
active: false,
createdAt: '2025-03-01T12:00:00.000Z',
updatedAt: '2025-03-12T10:15:00.000Z',
nodes: []
}
];
// Simple function to test tool definition
function getListWorkflowsToolDefinition() {
return {
name: 'list_workflows',
description: 'List all workflows with optional filtering by status',
inputSchema: {
type: 'object',
properties: {
active: {
type: 'boolean',
description: 'Filter workflows by active status'
}
},
required: []
}
};
}
// Simple function to test workflow filtering
function filterWorkflows(workflows, filter) {
if (filter && typeof filter.active === 'boolean') {
return workflows.filter(workflow => workflow.active === filter.active);
}
return workflows;
}
describe('Workflow Tools', () => {
describe('getListWorkflowsToolDefinition', () => {
it('should return the correct tool definition', () => {
const definition = getListWorkflowsToolDefinition();
expect(definition.name).toBe('list_workflows');
expect(definition.description).toBeTruthy();
expect(definition.inputSchema).toBeDefined();
expect(definition.inputSchema.properties).toHaveProperty('active');
expect(definition.inputSchema.required).toEqual([]);
});
});
describe('filterWorkflows', () => {
it('should return all workflows when no filter is provided', () => {
const result = filterWorkflows(mockWorkflows, {});
expect(result).toHaveLength(2);
expect(result).toEqual(mockWorkflows);
});
it('should filter workflows by active status when active is true', () => {
const result = filterWorkflows(mockWorkflows, { active: true });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1234abc');
expect(result[0].active).toBe(true);
});
it('should filter workflows by active status when active is false', () => {
const result = filterWorkflows(mockWorkflows, { active: false });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('5678def');
expect(result[0].active).toBe(false);
});
});
});

View File

@@ -11,10 +11,9 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "src",
"rootDir": ".", // Set rootDir to encompass both src and tests
"lib": [
"ES2020",
"DOM"
"ES2020" // Removed "DOM" as it's likely unused in a Node.js server
],
"types": [
"node"
@@ -28,4 +27,5 @@
"build",
"**/*.test.ts"
]
// Remove project reference
}