feat: Add workflow activation/deactivation via diff operations

Implements workflow activation and deactivation as diff operations in
n8n_update_partial_workflow tool, following the pattern of other
configuration operations.

Changes:
- Add activateWorkflow/deactivateWorkflow API methods
- Add operation types to diff engine
- Update tool documentation
- Remove activation limitation

Resolves #399
Credits: ArtemisAI, cmj-hub for investigation and initial implementation
Conceived by Romuald Członkowski - www.aiadvisors.pl/en
This commit is contained in:
czlonkowski
2025-11-06 22:49:46 +01:00
parent 3d5ceae43f
commit 346fa3c8d2
10 changed files with 248 additions and 13 deletions

View File

@@ -7,6 +7,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.22.11] - 2025-01-06
### ✨ New Features
**Issue #399: Workflow Activation via Diff Operations**
Added workflow activation and deactivation as diff operations in `n8n_update_partial_workflow`, using n8n's dedicated API endpoints.
#### Problem
The n8n API provides dedicated `POST /workflows/{id}/activate` and `POST /workflows/{id}/deactivate` endpoints, but these were not accessible through n8n-mcp. Users could not programmatically control workflow activation status, forcing manual activation through the n8n UI.
#### Solution
Implemented activation/deactivation as diff operations, following the established pattern of metadata operations like `updateSettings` and `updateName`. This keeps the tool count manageable (40 tools, not 42) and provides a consistent interface.
#### Changes
**API Client** (`src/services/n8n-api-client.ts`):
- Added `activateWorkflow(id: string): Promise<Workflow>` method
- Added `deactivateWorkflow(id: string): Promise<Workflow>` method
- Both use POST requests to dedicated n8n API endpoints
**Diff Engine Types** (`src/types/workflow-diff.ts`):
- Added `ActivateWorkflowOperation` interface
- Added `DeactivateWorkflowOperation` interface
- Added `shouldActivate` and `shouldDeactivate` flags to `WorkflowDiffResult`
- Increased supported operations from 15 to 17
**Diff Engine** (`src/services/workflow-diff-engine.ts`):
- Added validation for activation (requires activatable triggers)
- Added operation application logic
- Transfers activation intent from workflow object to result
- Validates workflow has activatable triggers (webhook, schedule, etc.)
- Rejects workflows with only `executeWorkflowTrigger` (cannot activate)
**Handler** (`src/mcp/handlers-workflow-diff.ts`):
- Checks `shouldActivate` and `shouldDeactivate` flags after workflow update
- Calls appropriate API methods
- Includes activation status in response message and details
- Handles activation/deactivation errors gracefully
**Documentation** (`src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts`):
- Updated operation count from 15 to 17
- Added "Workflow Activation Operations" section
- Added activation tip to essentials
**Tool Registration** (`src/mcp/handlers-n8n-manager.ts`):
- Removed "Cannot activate/deactivate workflows via API" from limitations
#### Usage
```javascript
// Activate workflow
n8n_update_partial_workflow({
id: "workflow_id",
operations: [{
type: "activateWorkflow"
}]
})
// Deactivate workflow
n8n_update_partial_workflow({
id: "workflow_id",
operations: [{
type: "deactivateWorkflow"
}]
})
// Combine with other operations
n8n_update_partial_workflow({
id: "workflow_id",
operations: [
{type: "updateNode", nodeId: "abc", updates: {name: "Updated"}},
{type: "activateWorkflow"}
]
})
```
#### Validation
- **Activation**: Requires at least one enabled activatable trigger node
- **Deactivation**: Always valid
- **Error Handling**: Clear messages when activation fails due to missing triggers
- **Trigger Detection**: Uses `isActivatableTrigger()` utility (Issue #351 compliance)
#### Benefits
- ✅ Consistent with existing architecture (metadata operations pattern)
- ✅ Keeps tool count at 40 (not 42)
- ✅ Atomic operations - activation happens after workflow update
- ✅ Proper validation - prevents activation without triggers
- ✅ Clear error messages - guides users on trigger requirements
- ✅ Works with other operations - can update and activate in one call
#### Credits
- **@ArtemisAI** - Original investigation and API endpoint discovery
- **@cmj-hub** - Implementation attempt and PR contribution
- Architectural guidance from project maintainer
Resolves #399
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
## [2.22.10] - 2025-11-04 ## [2.22.10] - 2025-11-04
### 🐛 Bug Fixes ### 🐛 Bug Fixes

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.22.10", "version": "2.22.11",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp-runtime", "name": "n8n-mcp-runtime",
"version": "2.22.10", "version": "2.22.11",
"description": "n8n MCP Server Runtime Dependencies Only", "description": "n8n MCP Server Runtime Dependencies Only",
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@@ -1561,7 +1561,6 @@ export async function handleListAvailableTools(context?: InstanceContext): Promi
maxRetries: config.maxRetries maxRetries: config.maxRetries
} : null, } : null,
limitations: [ limitations: [
'Cannot activate/deactivate workflows via API',
'Cannot execute workflows directly (must use webhooks)', 'Cannot execute workflows directly (must use webhooks)',
'Cannot stop running executions', 'Cannot stop running executions',
'Tags and credentials have limited API support' 'Tags and credentials have limited API support'

View File

@@ -245,15 +245,52 @@ export async function handleUpdatePartialWorkflow(
// Update workflow via API // Update workflow via API
try { try {
const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!); const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
// Handle activation/deactivation if requested
let finalWorkflow = updatedWorkflow;
let activationMessage = '';
if (diffResult.shouldActivate) {
try {
finalWorkflow = await client.activateWorkflow(input.id);
activationMessage = ' Workflow activated.';
} catch (activationError) {
logger.error('Failed to activate workflow after update', activationError);
return {
success: false,
error: 'Workflow updated successfully but activation failed',
details: {
workflowUpdated: true,
activationError: activationError instanceof Error ? activationError.message : 'Unknown error'
}
};
}
} else if (diffResult.shouldDeactivate) {
try {
finalWorkflow = await client.deactivateWorkflow(input.id);
activationMessage = ' Workflow deactivated.';
} catch (deactivationError) {
logger.error('Failed to deactivate workflow after update', deactivationError);
return {
success: false,
error: 'Workflow updated successfully but deactivation failed',
details: {
workflowUpdated: true,
deactivationError: deactivationError instanceof Error ? deactivationError.message : 'Unknown error'
}
};
}
}
return { return {
success: true, success: true,
data: updatedWorkflow, data: finalWorkflow,
message: `Workflow "${updatedWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.`, message: `Workflow "${finalWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.${activationMessage}`,
details: { details: {
operationsApplied: diffResult.operationsApplied, operationsApplied: diffResult.operationsApplied,
workflowId: updatedWorkflow.id, workflowId: finalWorkflow.id,
workflowName: updatedWorkflow.name, workflowName: finalWorkflow.name,
active: finalWorkflow.active,
applied: diffResult.applied, applied: diffResult.applied,
failed: diffResult.failed, failed: diffResult.failed,
errors: diffResult.errors, errors: diffResult.errors,

View File

@@ -4,7 +4,7 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
name: 'n8n_update_partial_workflow', name: 'n8n_update_partial_workflow',
category: 'workflow_management', category: 'workflow_management',
essentials: { essentials: {
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).', description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
keyParameters: ['id', 'operations', 'continueOnError'], keyParameters: ['id', 'operations', 'continueOnError'],
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})', example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
performance: 'Fast (50-200ms)', performance: 'Fast (50-200ms)',
@@ -19,11 +19,12 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)', 'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)',
'Batch AI component connections for atomic updates', 'Batch AI component connections for atomic updates',
'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)', 'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)',
'Node renames automatically update all connection references - no manual connection operations needed' 'Node renames automatically update all connection references - no manual connection operations needed',
'Activate/deactivate workflows: Use activateWorkflow/deactivateWorkflow operations (requires activatable triggers like webhook/schedule)'
] ]
}, },
full: { full: {
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 15 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied. description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
## Available Operations: ## Available Operations:
@@ -48,6 +49,10 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
- **addTag**: Add a workflow tag - **addTag**: Add a workflow tag
- **removeTag**: Remove a workflow tag - **removeTag**: Remove a workflow tag
### Workflow Activation Operations (2 types):
- **activateWorkflow**: Activate the workflow to enable automatic execution via triggers
- **deactivateWorkflow**: Deactivate the workflow to prevent automatic execution
## Smart Parameters for Multi-Output Nodes ## Smart Parameters for Multi-Output Nodes
For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex: For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex:

View File

@@ -170,6 +170,24 @@ export class N8nApiClient {
} }
} }
async activateWorkflow(id: string): Promise<Workflow> {
try {
const response = await this.client.post(`/workflows/${id}/activate`);
return response.data;
} catch (error) {
throw handleN8nApiError(error);
}
}
async deactivateWorkflow(id: string): Promise<Workflow> {
try {
const response = await this.client.post(`/workflows/${id}/deactivate`);
return response.data;
} catch (error) {
throw handleN8nApiError(error);
}
}
/** /**
* Lists workflows from n8n instance. * Lists workflows from n8n instance.
* *

View File

@@ -25,6 +25,8 @@ import {
UpdateNameOperation, UpdateNameOperation,
AddTagOperation, AddTagOperation,
RemoveTagOperation, RemoveTagOperation,
ActivateWorkflowOperation,
DeactivateWorkflowOperation,
CleanStaleConnectionsOperation, CleanStaleConnectionsOperation,
ReplaceConnectionsOperation ReplaceConnectionsOperation
} from '../types/workflow-diff'; } from '../types/workflow-diff';
@@ -32,6 +34,7 @@ import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
import { Logger } from '../utils/logger'; import { Logger } from '../utils/logger';
import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation'; import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation';
import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer'; import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer';
import { isActivatableTrigger } from '../utils/node-type-utils';
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' }); const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
@@ -214,12 +217,23 @@ export class WorkflowDiffEngine {
} }
const operationsApplied = request.operations.length; const operationsApplied = request.operations.length;
// Extract activation flags from workflow object
const shouldActivate = (workflowCopy as any)._shouldActivate === true;
const shouldDeactivate = (workflowCopy as any)._shouldDeactivate === true;
// Clean up temporary flags
delete (workflowCopy as any)._shouldActivate;
delete (workflowCopy as any)._shouldDeactivate;
return { return {
success: true, success: true,
workflow: workflowCopy, workflow: workflowCopy,
operationsApplied, operationsApplied,
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`, message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`,
warnings: this.warnings.length > 0 ? this.warnings : undefined warnings: this.warnings.length > 0 ? this.warnings : undefined,
shouldActivate: shouldActivate || undefined,
shouldDeactivate: shouldDeactivate || undefined
}; };
} }
} catch (error) { } catch (error) {
@@ -262,6 +276,10 @@ export class WorkflowDiffEngine {
case 'addTag': case 'addTag':
case 'removeTag': case 'removeTag':
return null; // These are always valid return null; // These are always valid
case 'activateWorkflow':
return this.validateActivateWorkflow(workflow, operation);
case 'deactivateWorkflow':
return this.validateDeactivateWorkflow(workflow, operation);
case 'cleanStaleConnections': case 'cleanStaleConnections':
return this.validateCleanStaleConnections(workflow, operation); return this.validateCleanStaleConnections(workflow, operation);
case 'replaceConnections': case 'replaceConnections':
@@ -315,6 +333,12 @@ export class WorkflowDiffEngine {
case 'removeTag': case 'removeTag':
this.applyRemoveTag(workflow, operation); this.applyRemoveTag(workflow, operation);
break; break;
case 'activateWorkflow':
this.applyActivateWorkflow(workflow, operation);
break;
case 'deactivateWorkflow':
this.applyDeactivateWorkflow(workflow, operation);
break;
case 'cleanStaleConnections': case 'cleanStaleConnections':
this.applyCleanStaleConnections(workflow, operation); this.applyCleanStaleConnections(workflow, operation);
break; break;
@@ -847,13 +871,46 @@ export class WorkflowDiffEngine {
private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void { private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void {
if (!workflow.tags) return; if (!workflow.tags) return;
const index = workflow.tags.indexOf(operation.tag); const index = workflow.tags.indexOf(operation.tag);
if (index !== -1) { if (index !== -1) {
workflow.tags.splice(index, 1); workflow.tags.splice(index, 1);
} }
} }
// Workflow activation operation validators
private validateActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): string | null {
// Check if workflow has at least one activatable trigger
// Issue #351: executeWorkflowTrigger cannot activate workflows
const activatableTriggers = workflow.nodes.filter(
node => !node.disabled && isActivatableTrigger(node.type)
);
if (activatableTriggers.length === 0) {
return 'Cannot activate workflow: No activatable trigger nodes found. Workflows must have at least one enabled trigger node (webhook, schedule, email, etc.). Note: executeWorkflowTrigger cannot activate workflows as they can only be invoked by other workflows.';
}
return null;
}
private validateDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): string | null {
// Deactivation is always valid - any workflow can be deactivated
return null;
}
// Workflow activation operation appliers
private applyActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): void {
// Set flag in workflow object to indicate activation intent
// The handler will call the API method after workflow update
(workflow as any)._shouldActivate = true;
}
private applyDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): void {
// Set flag in workflow object to indicate deactivation intent
// The handler will call the API method after workflow update
(workflow as any)._shouldDeactivate = true;
}
// Connection cleanup operation validators // Connection cleanup operation validators
private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null { private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null {
// This operation is always valid - it just cleans up what it finds // This operation is always valid - it just cleans up what it finds

View File

@@ -114,6 +114,16 @@ export interface RemoveTagOperation extends DiffOperation {
tag: string; tag: string;
} }
export interface ActivateWorkflowOperation extends DiffOperation {
type: 'activateWorkflow';
// No additional properties needed - just activates the workflow
}
export interface DeactivateWorkflowOperation extends DiffOperation {
type: 'deactivateWorkflow';
// No additional properties needed - just deactivates the workflow
}
// Connection Cleanup Operations // Connection Cleanup Operations
export interface CleanStaleConnectionsOperation extends DiffOperation { export interface CleanStaleConnectionsOperation extends DiffOperation {
type: 'cleanStaleConnections'; type: 'cleanStaleConnections';
@@ -148,6 +158,8 @@ export type WorkflowDiffOperation =
| UpdateNameOperation | UpdateNameOperation
| AddTagOperation | AddTagOperation
| RemoveTagOperation | RemoveTagOperation
| ActivateWorkflowOperation
| DeactivateWorkflowOperation
| CleanStaleConnectionsOperation | CleanStaleConnectionsOperation
| ReplaceConnectionsOperation; | ReplaceConnectionsOperation;
@@ -176,6 +188,8 @@ export interface WorkflowDiffResult {
applied?: number[]; // Indices of successfully applied operations (when continueOnError is true) applied?: number[]; // Indices of successfully applied operations (when continueOnError is true)
failed?: number[]; // Indices of failed operations (when continueOnError is true) failed?: number[]; // Indices of failed operations (when continueOnError is true)
staleConnectionsRemoved?: Array<{ from: string; to: string }>; // For cleanStaleConnections operation staleConnectionsRemoved?: Array<{ from: string; to: string }>; // For cleanStaleConnections operation
shouldActivate?: boolean; // Flag to activate workflow after update (for activateWorkflow operation)
shouldDeactivate?: boolean; // Flag to deactivate workflow after update (for deactivateWorkflow operation)
} }
// Helper type for node reference (supports both ID and name) // Helper type for node reference (supports both ID and name)