mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-09 06:43:08 +00:00
feat: implement n8n integration improvements and protocol version negotiation
- Add intelligent protocol version negotiation (2024-11-05 for n8n, 2025-03-26 for standard clients) - Fix memory leak potential with async cleanup and connection close handling - Enhance error sanitization for production environments - Add schema validation for n8n nested output workaround - Improve Docker security with unpredictable UIDs/GIDs - Create n8n-friendly tool descriptions to reduce schema validation errors - Add comprehensive protocol negotiation test suite Addresses code review feedback: - Protocol version inconsistency resolved - Memory management improved - Error information leakage fixed - Docker security enhanced 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ import { existsSync, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { n8nDocumentationToolsFinal } from './tools';
|
||||
import { n8nManagementTools } from './tools-n8n-manager';
|
||||
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
|
||||
import { getWorkflowExampleString } from './workflow-examples';
|
||||
import { logger } from '../utils/logger';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
||||
@@ -26,6 +28,11 @@ import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
||||
import { PROJECT_VERSION } from '../utils/version';
|
||||
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
||||
import {
|
||||
negotiateProtocolVersion,
|
||||
logProtocolNegotiation,
|
||||
STANDARD_PROTOCOL_VERSION
|
||||
} from '../utils/protocol-version';
|
||||
|
||||
interface NodeRow {
|
||||
node_type: string;
|
||||
@@ -52,6 +59,7 @@ export class N8NDocumentationMCPServer {
|
||||
private templateService: TemplateService | null = null;
|
||||
private initialized: Promise<void>;
|
||||
private cache = new SimpleCache();
|
||||
private clientInfo: any = null;
|
||||
|
||||
constructor() {
|
||||
// Check for test environment first
|
||||
@@ -154,9 +162,39 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
private setupHandlers(): void {
|
||||
// Handle initialization
|
||||
this.server.setRequestHandler(InitializeRequestSchema, async () => {
|
||||
this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
||||
const clientVersion = request.params.protocolVersion;
|
||||
const clientCapabilities = request.params.capabilities;
|
||||
const clientInfo = request.params.clientInfo;
|
||||
|
||||
logger.info('MCP Initialize request received', {
|
||||
clientVersion,
|
||||
clientCapabilities,
|
||||
clientInfo
|
||||
});
|
||||
|
||||
// Store client info for later use
|
||||
this.clientInfo = clientInfo;
|
||||
|
||||
// Negotiate protocol version based on client information
|
||||
const negotiationResult = negotiateProtocolVersion(
|
||||
clientVersion,
|
||||
clientInfo,
|
||||
undefined, // no user agent in MCP protocol
|
||||
undefined // no headers in MCP protocol
|
||||
);
|
||||
|
||||
logProtocolNegotiation(negotiationResult, logger, 'MCP_INITIALIZE');
|
||||
|
||||
// Warn if there's a version mismatch (for debugging)
|
||||
if (clientVersion && clientVersion !== negotiationResult.version) {
|
||||
logger.warn(`Protocol version negotiated: client requested ${clientVersion}, server will use ${negotiationResult.version}`, {
|
||||
reasoning: negotiationResult.reasoning
|
||||
});
|
||||
}
|
||||
|
||||
const response = {
|
||||
protocolVersion: '2024-11-05',
|
||||
protocolVersion: negotiationResult.version,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
@@ -166,18 +204,14 @@ export class N8NDocumentationMCPServer {
|
||||
},
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
if (process.env.DEBUG_MCP === 'true') {
|
||||
logger.debug('Initialize handler called', { response });
|
||||
}
|
||||
|
||||
logger.info('MCP Initialize response', { response });
|
||||
return response;
|
||||
});
|
||||
|
||||
// Handle tool listing
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
||||
// Combine documentation tools with management tools if API is configured
|
||||
const tools = [...n8nDocumentationToolsFinal];
|
||||
let tools = [...n8nDocumentationToolsFinal];
|
||||
const isConfigured = isN8nApiConfigured();
|
||||
|
||||
if (isConfigured) {
|
||||
@@ -187,6 +221,27 @@ export class N8NDocumentationMCPServer {
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`);
|
||||
}
|
||||
|
||||
// Check if client is n8n (from initialization)
|
||||
const clientInfo = this.clientInfo;
|
||||
const isN8nClient = clientInfo?.name?.includes('n8n') ||
|
||||
clientInfo?.name?.includes('langchain');
|
||||
|
||||
if (isN8nClient) {
|
||||
logger.info('Detected n8n client, using n8n-friendly tool descriptions');
|
||||
tools = makeToolsN8nFriendly(tools);
|
||||
}
|
||||
|
||||
// Log validation tools' input schemas for debugging
|
||||
const validationTools = tools.filter(t => t.name.startsWith('validate_'));
|
||||
validationTools.forEach(tool => {
|
||||
logger.info('Validation tool schema', {
|
||||
toolName: tool.name,
|
||||
inputSchema: JSON.stringify(tool.inputSchema, null, 2),
|
||||
hasOutputSchema: !!tool.outputSchema,
|
||||
description: tool.description
|
||||
});
|
||||
});
|
||||
|
||||
return { tools };
|
||||
});
|
||||
|
||||
@@ -194,25 +249,124 @@ export class N8NDocumentationMCPServer {
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
// Enhanced logging for debugging tool calls
|
||||
logger.info('Tool call received - DETAILED DEBUG', {
|
||||
toolName: name,
|
||||
arguments: JSON.stringify(args, null, 2),
|
||||
argumentsType: typeof args,
|
||||
argumentsKeys: args ? Object.keys(args) : [],
|
||||
hasNodeType: args && 'nodeType' in args,
|
||||
hasConfig: args && 'config' in args,
|
||||
configType: args && args.config ? typeof args.config : 'N/A',
|
||||
rawRequest: JSON.stringify(request.params)
|
||||
});
|
||||
|
||||
// Workaround for n8n's nested output bug
|
||||
// Check if args contains nested 'output' structure from n8n's memory corruption
|
||||
let processedArgs = args;
|
||||
if (args && typeof args === 'object' && 'output' in args) {
|
||||
try {
|
||||
const possibleNestedData = args.output;
|
||||
// If output is a string that looks like JSON, try to parse it
|
||||
if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) {
|
||||
const parsed = JSON.parse(possibleNestedData);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', {
|
||||
originalArgs: args,
|
||||
extractedArgs: parsed
|
||||
});
|
||||
|
||||
// Validate the extracted arguments match expected tool schema
|
||||
if (this.validateExtractedArgs(name, parsed)) {
|
||||
// Use the extracted data as args
|
||||
processedArgs = parsed;
|
||||
} else {
|
||||
logger.warn('Extracted arguments failed validation, using original args', {
|
||||
toolName: name,
|
||||
extractedArgs: parsed
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.debug('Failed to parse nested output, continuing with original args', {
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Executing tool: ${name}`, { args });
|
||||
const result = await this.executeTool(name, args);
|
||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const result = await this.executeTool(name, processedArgs);
|
||||
logger.debug(`Tool ${name} executed successfully`);
|
||||
return {
|
||||
|
||||
// Ensure the result is properly formatted for MCP
|
||||
let responseText: string;
|
||||
let structuredContent: any = null;
|
||||
|
||||
try {
|
||||
// For validation tools, check if we should use structured content
|
||||
if (name.startsWith('validate_') && typeof result === 'object' && result !== null) {
|
||||
// Clean up the result to ensure it matches the outputSchema
|
||||
const cleanResult = this.sanitizeValidationResult(result, name);
|
||||
structuredContent = cleanResult;
|
||||
responseText = JSON.stringify(cleanResult, null, 2);
|
||||
} else {
|
||||
responseText = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
}
|
||||
} catch (jsonError) {
|
||||
logger.warn(`Failed to stringify tool result for ${name}:`, jsonError);
|
||||
responseText = String(result);
|
||||
}
|
||||
|
||||
// Validate response size (n8n might have limits)
|
||||
if (responseText.length > 1000000) { // 1MB limit
|
||||
logger.warn(`Tool ${name} response is very large (${responseText.length} chars), truncating`);
|
||||
responseText = responseText.substring(0, 999000) + '\n\n[Response truncated due to size limits]';
|
||||
structuredContent = null; // Don't use structured content for truncated responses
|
||||
}
|
||||
|
||||
// Build MCP response with strict schema compliance
|
||||
const mcpResponse: any = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
type: 'text' as const,
|
||||
text: responseText,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// For tools with outputSchema, structuredContent is REQUIRED by MCP spec
|
||||
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||
mcpResponse.structuredContent = structuredContent;
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${name}`, error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Provide more helpful error messages for common n8n issues
|
||||
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
|
||||
|
||||
if (errorMessage.includes('required') || errorMessage.includes('missing')) {
|
||||
helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.';
|
||||
} else if (errorMessage.includes('type') || errorMessage.includes('expected')) {
|
||||
helpfulMessage += '\n\nNote: This error indicates a type mismatch. The AI agent may be sending data in the wrong format (e.g., string instead of object).';
|
||||
} else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) {
|
||||
helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.';
|
||||
}
|
||||
|
||||
// For n8n schema errors, add specific guidance
|
||||
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
|
||||
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error executing tool ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
text: helpfulMessage,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
@@ -221,6 +375,90 @@ export class N8NDocumentationMCPServer {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize validation result to match outputSchema
|
||||
*/
|
||||
private sanitizeValidationResult(result: any, toolName: string): any {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const sanitized = { ...result };
|
||||
|
||||
// Ensure required fields exist with proper types and filter to schema-defined fields only
|
||||
if (toolName === 'validate_node_minimal') {
|
||||
// Filter to only schema-defined fields
|
||||
const filtered = {
|
||||
nodeType: String(sanitized.nodeType || ''),
|
||||
displayName: String(sanitized.displayName || ''),
|
||||
valid: Boolean(sanitized.valid),
|
||||
missingRequiredFields: Array.isArray(sanitized.missingRequiredFields)
|
||||
? sanitized.missingRequiredFields.map(String)
|
||||
: []
|
||||
};
|
||||
return filtered;
|
||||
} else if (toolName === 'validate_node_operation') {
|
||||
// Ensure summary exists
|
||||
let summary = sanitized.summary;
|
||||
if (!summary || typeof summary !== 'object') {
|
||||
summary = {
|
||||
hasErrors: Array.isArray(sanitized.errors) ? sanitized.errors.length > 0 : false,
|
||||
errorCount: Array.isArray(sanitized.errors) ? sanitized.errors.length : 0,
|
||||
warningCount: Array.isArray(sanitized.warnings) ? sanitized.warnings.length : 0,
|
||||
suggestionCount: Array.isArray(sanitized.suggestions) ? sanitized.suggestions.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
// Filter to only schema-defined fields
|
||||
const filtered = {
|
||||
nodeType: String(sanitized.nodeType || ''),
|
||||
workflowNodeType: String(sanitized.workflowNodeType || sanitized.nodeType || ''),
|
||||
displayName: String(sanitized.displayName || ''),
|
||||
valid: Boolean(sanitized.valid),
|
||||
errors: Array.isArray(sanitized.errors) ? sanitized.errors : [],
|
||||
warnings: Array.isArray(sanitized.warnings) ? sanitized.warnings : [],
|
||||
suggestions: Array.isArray(sanitized.suggestions) ? sanitized.suggestions : [],
|
||||
summary: summary
|
||||
};
|
||||
return filtered;
|
||||
} else if (toolName.startsWith('validate_workflow')) {
|
||||
sanitized.valid = Boolean(sanitized.valid);
|
||||
|
||||
// Ensure arrays exist
|
||||
sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : [];
|
||||
sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : [];
|
||||
|
||||
// Ensure statistics/summary exists
|
||||
if (toolName === 'validate_workflow') {
|
||||
if (!sanitized.summary || typeof sanitized.summary !== 'object') {
|
||||
sanitized.summary = {
|
||||
totalNodes: 0,
|
||||
enabledNodes: 0,
|
||||
triggerNodes: 0,
|
||||
validConnections: 0,
|
||||
invalidConnections: 0,
|
||||
expressionsValidated: 0,
|
||||
errorCount: sanitized.errors.length,
|
||||
warningCount: sanitized.warnings.length
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (!sanitized.statistics || typeof sanitized.statistics !== 'object') {
|
||||
sanitized.statistics = {
|
||||
totalNodes: 0,
|
||||
triggerNodes: 0,
|
||||
validConnections: 0,
|
||||
invalidConnections: 0,
|
||||
expressionsValidated: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove undefined values to ensure clean JSON
|
||||
return JSON.parse(JSON.stringify(sanitized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required parameters for tool execution
|
||||
*/
|
||||
@@ -238,10 +476,95 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate extracted arguments match expected tool schema
|
||||
*/
|
||||
private validateExtractedArgs(toolName: string, args: any): boolean {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get all available tools
|
||||
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool || !tool.inputSchema) {
|
||||
return true; // If no schema, assume valid
|
||||
}
|
||||
|
||||
const schema = tool.inputSchema;
|
||||
const required = schema.required || [];
|
||||
const properties = schema.properties || {};
|
||||
|
||||
// Check all required fields are present
|
||||
for (const requiredField of required) {
|
||||
if (!(requiredField in args)) {
|
||||
logger.debug(`Extracted args missing required field: ${requiredField}`, {
|
||||
toolName,
|
||||
extractedArgs: args,
|
||||
required
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check field types match schema
|
||||
for (const [fieldName, fieldValue] of Object.entries(args)) {
|
||||
if (properties[fieldName]) {
|
||||
const expectedType = properties[fieldName].type;
|
||||
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
|
||||
|
||||
// Basic type validation
|
||||
if (expectedType && expectedType !== actualType) {
|
||||
// Special case: number can be coerced from string
|
||||
if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Extracted args field type mismatch: ${fieldName}`, {
|
||||
toolName,
|
||||
expectedType,
|
||||
actualType,
|
||||
fieldValue
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for extraneous fields if additionalProperties is false
|
||||
if (schema.additionalProperties === false) {
|
||||
const allowedFields = Object.keys(properties);
|
||||
const extraFields = Object.keys(args).filter(field => !allowedFields.includes(field));
|
||||
|
||||
if (extraFields.length > 0) {
|
||||
logger.debug(`Extracted args have extra fields`, {
|
||||
toolName,
|
||||
extraFields,
|
||||
allowedFields
|
||||
});
|
||||
// For n8n compatibility, we'll still consider this valid but log it
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
// Ensure args is an object
|
||||
// Ensure args is an object and validate it
|
||||
args = args || {};
|
||||
|
||||
// Log the tool call for debugging n8n issues
|
||||
logger.info(`Tool execution: ${name}`, {
|
||||
args: typeof args === 'object' ? JSON.stringify(args) : args,
|
||||
argsType: typeof args,
|
||||
argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object'
|
||||
});
|
||||
|
||||
// Validate that args is actually an object
|
||||
if (typeof args !== 'object' || args === null) {
|
||||
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'tools_documentation':
|
||||
// No required parameters
|
||||
@@ -281,9 +604,43 @@ export class N8NDocumentationMCPServer {
|
||||
return this.listTasks(args.category);
|
||||
case 'validate_node_operation':
|
||||
this.validateToolParams(name, args, ['nodeType', 'config']);
|
||||
// Ensure config is an object
|
||||
if (typeof args.config !== 'object' || args.config === null) {
|
||||
logger.warn(`validate_node_operation called with invalid config type: ${typeof args.config}`);
|
||||
return {
|
||||
nodeType: args.nodeType || 'unknown',
|
||||
workflowNodeType: args.nodeType || 'unknown',
|
||||
displayName: 'Unknown Node',
|
||||
valid: false,
|
||||
errors: [{
|
||||
type: 'config',
|
||||
property: 'config',
|
||||
message: 'Invalid config format - expected object',
|
||||
fix: 'Provide config as an object with node properties'
|
||||
}],
|
||||
warnings: [],
|
||||
suggestions: [],
|
||||
summary: {
|
||||
hasErrors: true,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
suggestionCount: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
|
||||
case 'validate_node_minimal':
|
||||
this.validateToolParams(name, args, ['nodeType', 'config']);
|
||||
// Ensure config is an object
|
||||
if (typeof args.config !== 'object' || args.config === null) {
|
||||
logger.warn(`validate_node_minimal called with invalid config type: ${typeof args.config}`);
|
||||
return {
|
||||
nodeType: args.nodeType || 'unknown',
|
||||
displayName: 'Unknown Node',
|
||||
valid: false,
|
||||
missingRequiredFields: ['Invalid config format - expected object']
|
||||
};
|
||||
}
|
||||
return this.validateNodeMinimal(args.nodeType, args.config);
|
||||
case 'get_property_dependencies':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
@@ -1909,6 +2266,56 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
// Enhanced logging for workflow validation
|
||||
logger.info('Workflow validation requested', {
|
||||
hasWorkflow: !!workflow,
|
||||
workflowType: typeof workflow,
|
||||
hasNodes: workflow?.nodes !== undefined,
|
||||
nodesType: workflow?.nodes ? typeof workflow.nodes : 'undefined',
|
||||
nodesIsArray: Array.isArray(workflow?.nodes),
|
||||
nodesCount: Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0,
|
||||
hasConnections: workflow?.connections !== undefined,
|
||||
connectionsType: workflow?.connections ? typeof workflow.connections : 'undefined',
|
||||
options: options
|
||||
});
|
||||
|
||||
// Help n8n AI agents with common mistakes
|
||||
if (!workflow || typeof workflow !== 'object') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
node: 'workflow',
|
||||
message: 'Workflow must be an object with nodes and connections',
|
||||
details: 'Expected format: ' + getWorkflowExampleString()
|
||||
}],
|
||||
summary: { errorCount: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
node: 'workflow',
|
||||
message: 'Workflow must have a nodes array',
|
||||
details: 'Expected: workflow.nodes = [array of node objects]. ' + getWorkflowExampleString()
|
||||
}],
|
||||
summary: { errorCount: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
if (!workflow.connections || typeof workflow.connections !== 'object') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
node: 'workflow',
|
||||
message: 'Workflow must have a connections object',
|
||||
details: 'Expected: workflow.connections = {} (can be empty object). ' + getWorkflowExampleString()
|
||||
}],
|
||||
summary: { errorCount: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
// Create workflow validator instance
|
||||
const validator = new WorkflowValidator(
|
||||
this.repository,
|
||||
|
||||
175
src/mcp/tools-n8n-friendly.ts
Normal file
175
src/mcp/tools-n8n-friendly.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* n8n-friendly tool descriptions
|
||||
* These descriptions are optimized to reduce schema validation errors in n8n's AI Agent
|
||||
*
|
||||
* Key principles:
|
||||
* 1. Use exact JSON examples in descriptions
|
||||
* 2. Be explicit about data types
|
||||
* 3. Keep descriptions short and directive
|
||||
* 4. Avoid ambiguity
|
||||
*/
|
||||
|
||||
export const n8nFriendlyDescriptions: Record<string, {
|
||||
description: string;
|
||||
params: Record<string, string>;
|
||||
}> = {
|
||||
// Validation tools - most prone to errors
|
||||
validate_node_operation: {
|
||||
description: 'Validate n8n node. ALWAYS pass two parameters: nodeType (string) and config (object). Example call: {"nodeType": "nodes-base.slack", "config": {"resource": "channel", "operation": "create"}}',
|
||||
params: {
|
||||
nodeType: 'String value like "nodes-base.slack"',
|
||||
config: 'Object value like {"resource": "channel", "operation": "create"} or empty object {}',
|
||||
profile: 'Optional string: "minimal" or "runtime" or "ai-friendly" or "strict"'
|
||||
}
|
||||
},
|
||||
|
||||
validate_node_minimal: {
|
||||
description: 'Check required fields. MUST pass: nodeType (string) and config (object). Example: {"nodeType": "nodes-base.webhook", "config": {}}',
|
||||
params: {
|
||||
nodeType: 'String like "nodes-base.webhook"',
|
||||
config: 'Object, use {} for empty'
|
||||
}
|
||||
},
|
||||
|
||||
// Search and info tools
|
||||
search_nodes: {
|
||||
description: 'Search nodes. Pass query (string). Example: {"query": "webhook"}',
|
||||
params: {
|
||||
query: 'String keyword like "webhook" or "database"',
|
||||
limit: 'Optional number, default 20'
|
||||
}
|
||||
},
|
||||
|
||||
get_node_info: {
|
||||
description: 'Get node details. Pass nodeType (string). Example: {"nodeType": "nodes-base.httpRequest"}',
|
||||
params: {
|
||||
nodeType: 'String with prefix like "nodes-base.httpRequest"'
|
||||
}
|
||||
},
|
||||
|
||||
get_node_essentials: {
|
||||
description: 'Get node basics. Pass nodeType (string). Example: {"nodeType": "nodes-base.slack"}',
|
||||
params: {
|
||||
nodeType: 'String with prefix like "nodes-base.slack"'
|
||||
}
|
||||
},
|
||||
|
||||
// Task tools
|
||||
get_node_for_task: {
|
||||
description: 'Find node for task. Pass task (string). Example: {"task": "send_http_request"}',
|
||||
params: {
|
||||
task: 'String task name like "send_http_request"'
|
||||
}
|
||||
},
|
||||
|
||||
list_tasks: {
|
||||
description: 'List tasks by category. Pass category (string). Example: {"category": "HTTP/API"}',
|
||||
params: {
|
||||
category: 'String: "HTTP/API" or "Webhooks" or "Database" or "AI/LangChain" or "Data Processing" or "Communication"'
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow validation
|
||||
validate_workflow: {
|
||||
description: 'Validate workflow. Pass workflow object. MUST have: {"workflow": {"nodes": [array of node objects], "connections": {object with node connections}}}. Each node needs: name, type, typeVersion, position.',
|
||||
params: {
|
||||
workflow: 'Object with two required fields: nodes (array) and connections (object). Example: {"nodes": [{"name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [250, 300], "parameters": {}}], "connections": {}}',
|
||||
options: 'Optional object. Example: {"validateNodes": true, "profile": "runtime"}'
|
||||
}
|
||||
},
|
||||
|
||||
validate_workflow_connections: {
|
||||
description: 'Validate workflow connections only. Pass workflow object. Example: {"workflow": {"nodes": [...], "connections": {}}}',
|
||||
params: {
|
||||
workflow: 'Object with nodes array and connections object. Minimal example: {"nodes": [{"name": "Webhook"}], "connections": {}}'
|
||||
}
|
||||
},
|
||||
|
||||
validate_workflow_expressions: {
|
||||
description: 'Validate n8n expressions in workflow. Pass workflow object. Example: {"workflow": {"nodes": [...], "connections": {}}}',
|
||||
params: {
|
||||
workflow: 'Object with nodes array and connections object containing n8n expressions like {{ $json.data }}'
|
||||
}
|
||||
},
|
||||
|
||||
// Property tools
|
||||
get_property_dependencies: {
|
||||
description: 'Get field dependencies. Pass nodeType (string) and optional config (object). Example: {"nodeType": "nodes-base.httpRequest", "config": {}}',
|
||||
params: {
|
||||
nodeType: 'String like "nodes-base.httpRequest"',
|
||||
config: 'Optional object, use {} for empty'
|
||||
}
|
||||
},
|
||||
|
||||
// AI tool info
|
||||
get_node_as_tool_info: {
|
||||
description: 'Get AI tool usage. Pass nodeType (string). Example: {"nodeType": "nodes-base.slack"}',
|
||||
params: {
|
||||
nodeType: 'String with prefix like "nodes-base.slack"'
|
||||
}
|
||||
},
|
||||
|
||||
// Template tools
|
||||
search_templates: {
|
||||
description: 'Search workflow templates. Pass query (string). Example: {"query": "chatbot"}',
|
||||
params: {
|
||||
query: 'String keyword like "chatbot" or "webhook"',
|
||||
limit: 'Optional number, default 20'
|
||||
}
|
||||
},
|
||||
|
||||
get_template: {
|
||||
description: 'Get template by ID. Pass templateId (number). Example: {"templateId": 1234}',
|
||||
params: {
|
||||
templateId: 'Number ID like 1234'
|
||||
}
|
||||
},
|
||||
|
||||
// Documentation tool
|
||||
tools_documentation: {
|
||||
description: 'Get tool docs. Pass optional depth (string). Example: {"depth": "essentials"} or {}',
|
||||
params: {
|
||||
depth: 'Optional string: "essentials" or "overview" or "detailed"',
|
||||
topic: 'Optional string topic name'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply n8n-friendly descriptions to tools
|
||||
* This function modifies tool descriptions to be more explicit for n8n's AI agent
|
||||
*/
|
||||
export function makeToolsN8nFriendly(tools: any[]): any[] {
|
||||
return tools.map(tool => {
|
||||
const toolName = tool.name as string;
|
||||
const friendlyDesc = n8nFriendlyDescriptions[toolName];
|
||||
if (friendlyDesc) {
|
||||
// Clone the tool to avoid mutating the original
|
||||
const updatedTool = { ...tool };
|
||||
|
||||
// Update the main description
|
||||
updatedTool.description = friendlyDesc.description;
|
||||
|
||||
// Clone inputSchema if it exists
|
||||
if (tool.inputSchema?.properties) {
|
||||
updatedTool.inputSchema = {
|
||||
...tool.inputSchema,
|
||||
properties: { ...tool.inputSchema.properties }
|
||||
};
|
||||
|
||||
// Update parameter descriptions
|
||||
Object.keys(updatedTool.inputSchema.properties).forEach(param => {
|
||||
if (friendlyDesc.params[param]) {
|
||||
updatedTool.inputSchema.properties[param] = {
|
||||
...updatedTool.inputSchema.properties[param],
|
||||
description: friendlyDesc.params[param]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return updatedTool;
|
||||
}
|
||||
return tool;
|
||||
});
|
||||
}
|
||||
198
src/mcp/tools.ts
198
src/mcp/tools.ts
@@ -59,7 +59,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'get_node_info',
|
||||
description: `Get FULL node schema (100KB+). TIP: Use get_node_essentials first! Returns all properties/operations/credentials. Prefix required: "nodes-base.httpRequest" not "httpRequest".`,
|
||||
description: `Get full node documentation. Pass nodeType as string with prefix. Example: nodeType="nodes-base.webhook"`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -73,7 +73,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: `Search nodes by keywords. Modes: OR (any word), AND (all words), FUZZY (typos OK). Primary nodes ranked first. Examples: "webhook"→Webhook, "http call"→HTTP Request.`,
|
||||
description: `Search n8n nodes by keyword. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -128,7 +128,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'get_node_essentials',
|
||||
description: `Get 10-20 key properties only (<5KB vs 100KB+). USE THIS FIRST! Includes examples. Format: "nodes-base.httpRequest"`,
|
||||
description: `Get node essential info. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack"`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -192,44 +192,103 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'validate_node_operation',
|
||||
description: `Validate node config. Checks required fields, types, operation rules. Returns errors with fixes. Essential for Slack/Sheets/DB nodes.`,
|
||||
description: `Validate n8n node configuration. Pass nodeType as string and config as object. Example: nodeType="nodes-base.slack", config={resource:"channel",operation:"create"}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type to validate (e.g., "nodes-base.slack")',
|
||||
description: 'Node type as string. Example: "nodes-base.slack"',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'Your node configuration. Must include operation fields (resource/operation/action) if the node has multiple operations.',
|
||||
description: 'Configuration as object. For simple nodes use {}. For complex nodes include fields like {resource:"channel",operation:"create"}',
|
||||
},
|
||||
profile: {
|
||||
type: 'string',
|
||||
enum: ['strict', 'runtime', 'ai-friendly', 'minimal'],
|
||||
description: 'Validation profile: minimal (only required fields), runtime (critical errors only), ai-friendly (balanced - default), strict (all checks including best practices)',
|
||||
description: 'Profile string: "minimal", "runtime", "ai-friendly", or "strict". Default is "ai-friendly"',
|
||||
default: 'ai-friendly',
|
||||
},
|
||||
},
|
||||
required: ['nodeType', 'config'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: { type: 'string' },
|
||||
workflowNodeType: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
valid: { type: 'boolean' },
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
property: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
fix: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
warnings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
property: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
suggestion: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
suggestions: { type: 'array', items: { type: 'string' } },
|
||||
summary: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hasErrors: { type: 'boolean' },
|
||||
errorCount: { type: 'number' },
|
||||
warningCount: { type: 'number' },
|
||||
suggestionCount: { type: 'number' }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['nodeType', 'displayName', 'valid', 'errors', 'warnings', 'suggestions', 'summary']
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_node_minimal',
|
||||
description: `Fast check for missing required fields only. No warnings/suggestions. Returns: list of missing fields.`,
|
||||
description: `Check n8n node required fields. Pass nodeType as string and config as empty object {}. Example: nodeType="nodes-base.webhook", config={}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type to validate (e.g., "nodes-base.slack")',
|
||||
description: 'Node type as string. Example: "nodes-base.slack"',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'The node configuration to check',
|
||||
description: 'Configuration object. Always pass {} for empty config',
|
||||
},
|
||||
},
|
||||
required: ['nodeType', 'config'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: { type: 'string' },
|
||||
displayName: { type: 'string' },
|
||||
valid: { type: 'boolean' },
|
||||
missingRequiredFields: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['nodeType', 'displayName', 'valid', 'missingRequiredFields']
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -306,7 +365,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query for template names/descriptions. NOT for node types! Examples: "chatbot", "automation", "social media", "webhook". For node-based search use list_node_templates instead.',
|
||||
description: 'Search keyword as string. Example: "chatbot"',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
@@ -382,6 +441,50 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
valid: { type: 'boolean' },
|
||||
summary: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalNodes: { type: 'number' },
|
||||
enabledNodes: { type: 'number' },
|
||||
triggerNodes: { type: 'number' },
|
||||
validConnections: { type: 'number' },
|
||||
invalidConnections: { type: 'number' },
|
||||
expressionsValidated: { type: 'number' },
|
||||
errorCount: { type: 'number' },
|
||||
warningCount: { type: 'number' }
|
||||
}
|
||||
},
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
details: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
warnings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
details: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
suggestions: { type: 'array', items: { type: 'string' } }
|
||||
},
|
||||
required: ['valid', 'summary']
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -396,6 +499,43 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
valid: { type: 'boolean' },
|
||||
statistics: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalNodes: { type: 'number' },
|
||||
triggerNodes: { type: 'number' },
|
||||
validConnections: { type: 'number' },
|
||||
invalidConnections: { type: 'number' }
|
||||
}
|
||||
},
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
warnings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['valid', 'statistics']
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -410,6 +550,42 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
valid: { type: 'boolean' },
|
||||
statistics: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalNodes: { type: 'number' },
|
||||
expressionsValidated: { type: 'number' }
|
||||
}
|
||||
},
|
||||
errors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
warnings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
node: { type: 'string' },
|
||||
message: { type: 'string' }
|
||||
}
|
||||
}
|
||||
},
|
||||
tips: { type: 'array', items: { type: 'string' } }
|
||||
},
|
||||
required: ['valid', 'statistics']
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
112
src/mcp/workflow-examples.ts
Normal file
112
src/mcp/workflow-examples.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Example workflows for n8n AI agents to understand the structure
|
||||
*/
|
||||
|
||||
export const MINIMAL_WORKFLOW_EXAMPLE = {
|
||||
nodes: [
|
||||
{
|
||||
name: "Webhook",
|
||||
type: "n8n-nodes-base.webhook",
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: "POST",
|
||||
path: "webhook"
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
export const SIMPLE_WORKFLOW_EXAMPLE = {
|
||||
nodes: [
|
||||
{
|
||||
name: "Webhook",
|
||||
type: "n8n-nodes-base.webhook",
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: "POST",
|
||||
path: "webhook"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Set",
|
||||
type: "n8n-nodes-base.set",
|
||||
typeVersion: 2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
mode: "manual",
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
name: "message",
|
||||
type: "string",
|
||||
value: "Hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Respond to Webhook",
|
||||
type: "n8n-nodes-base.respondToWebhook",
|
||||
typeVersion: 1,
|
||||
position: [650, 300],
|
||||
parameters: {
|
||||
respondWith: "firstIncomingItem"
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Set": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Respond to Webhook",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getWorkflowExampleString(): string {
|
||||
return `Example workflow structure:
|
||||
${JSON.stringify(MINIMAL_WORKFLOW_EXAMPLE, null, 2)}
|
||||
|
||||
Each node MUST have:
|
||||
- name: unique string identifier
|
||||
- type: full node type with prefix (e.g., "n8n-nodes-base.webhook")
|
||||
- typeVersion: number (usually 1 or 2)
|
||||
- position: [x, y] coordinates array
|
||||
- parameters: object with node-specific settings
|
||||
|
||||
Connections format:
|
||||
{
|
||||
"SourceNodeName": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "TargetNodeName",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}`;
|
||||
}
|
||||
Reference in New Issue
Block a user