Compare commits

2 Commits
main ... dev

Author SHA1 Message Date
leonardsellem
a532bde9cd docs: Clarify MCP server setup in README 2025-03-31 11:30:02 +02:00
leonardsellem
ecd9133437 feat: Refactor codebase, improve types, attempt test fixes 2025-03-31 11:20:05 +02:00
38 changed files with 867 additions and 821 deletions

View File

@@ -53,9 +53,9 @@ Configure the following environment variables:
| Variable | Description | Example |
|----------|-------------|---------|
| `N8N_API_URL` | URL of the n8n API | `http://localhost:5678/api/v1` |
| `N8N_API_URL` | Full URL of the n8n API, including `/api/v1` | `http://localhost:5678/api/v1` |
| `N8N_API_KEY` | API key for authenticating with n8n | `n8n_api_...` |
| `N8N_WEBHOOK_USERNAME` | Username for webhook authentication | `username` |
| `N8N_WEBHOOK_USERNAME` | Username for webhook authentication (if using webhooks) | `username` |
| `N8N_WEBHOOK_PASSWORD` | Password for webhook authentication | `password` |
| `DEBUG` | Enable debug logging (optional) | `true` or `false` |
@@ -84,19 +84,47 @@ n8n-mcp-server
### Integrating with AI Assistants
To use this MCP server with AI assistants, you need to register it with your AI assistant platform. The exact method depends on the platform you're using.
After building the server (`npm run build`), you need to configure your AI assistant (like VS Code with the Claude extension or the Claude Desktop app) to run it. This typically involves editing a JSON configuration file.
For example, with the MCP installer:
**Example Configuration (e.g., in VS Code `settings.json` or Claude Desktop `claude_desktop_config.json`):**
```bash
npx @anaisbetts/mcp-installer
```json
{
"mcpServers": {
// Give your server a unique name
"n8n-local": {
// Use 'node' to execute the built JavaScript file
"command": "node",
// Provide the *absolute path* to the built index.js file
"args": [
"/path/to/your/cloned/n8n-mcp-server/build/index.js"
// On Windows, use double backslashes:
// "C:\\path\\to\\your\\cloned\\n8n-mcp-server\\build\\index.js"
],
// Environment variables needed by the server
"env": {
"N8N_API_URL": "http://your-n8n-instance:5678/api/v1", // Replace with your n8n URL
"N8N_API_KEY": "YOUR_N8N_API_KEY", // Replace with your key
// Add webhook credentials only if you plan to use webhook tools
// "N8N_WEBHOOK_USERNAME": "your_webhook_user",
// "N8N_WEBHOOK_PASSWORD": "your_webhook_password"
},
// Ensure the server is enabled
"disabled": false,
// Default autoApprove settings
"autoApprove": []
}
// ... other servers might be configured here
}
}
```
Then register the server:
**Key Points:**
```
install_local_mcp_server path/to/n8n-mcp-server
```
* Replace `/path/to/your/cloned/n8n-mcp-server/` with the actual absolute path where you cloned and built the repository.
* Use the correct path separator for your operating system (forward slashes `/` for macOS/Linux, double backslashes `\\` for Windows).
* Ensure you provide the correct `N8N_API_URL` (including `/api/v1`) and `N8N_API_KEY`.
* The server needs to be built (`npm run build`) before the assistant can run the `build/index.js` file.
## Available Tools
@@ -124,30 +152,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
}