- Add centralized normalizeNodeType utility to handle prefix conversion - n8n-nodes-base.* → nodes-base.* - @n8n/n8n-nodes-langchain.* → nodes-langchain.* - Update all 9 affected MCP tools to use normalized node types - AI agents can now use node types directly from n8n workflow exports - Maintains backward compatibility with existing shortened prefixes - Add comprehensive test coverage for all affected methods Fixes #71 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2011 lines
66 KiB
TypeScript
2011 lines
66 KiB
TypeScript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
InitializeRequestSchema,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { n8nDocumentationToolsFinal } from './tools';
|
|
import { n8nManagementTools } from './tools-n8n-manager';
|
|
import { logger } from '../utils/logger';
|
|
import { NodeRepository } from '../database/node-repository';
|
|
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
|
import { PropertyFilter } from '../services/property-filter';
|
|
import { TaskTemplates } from '../services/task-templates';
|
|
import { ConfigValidator } from '../services/config-validator';
|
|
import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '../services/enhanced-config-validator';
|
|
import { PropertyDependencies } from '../services/property-dependencies';
|
|
import { SimpleCache } from '../utils/simple-cache';
|
|
import { TemplateService } from '../templates/template-service';
|
|
import { WorkflowValidator } from '../services/workflow-validator';
|
|
import { isN8nApiConfigured } from '../config/n8n-api';
|
|
import * as n8nHandlers from './handlers-n8n-manager';
|
|
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
|
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
|
import { PROJECT_VERSION } from '../utils/version';
|
|
import { normalizeNodeType, getNodeTypeAlternatives } from '../utils/node-utils';
|
|
|
|
interface NodeRow {
|
|
node_type: string;
|
|
package_name: string;
|
|
display_name: string;
|
|
description?: string;
|
|
category?: string;
|
|
development_style?: string;
|
|
is_ai_tool: number;
|
|
is_trigger: number;
|
|
is_webhook: number;
|
|
is_versioned: number;
|
|
version?: string;
|
|
documentation?: string;
|
|
properties_schema?: string;
|
|
operations?: string;
|
|
credentials_required?: string;
|
|
}
|
|
|
|
export class N8NDocumentationMCPServer {
|
|
private server: Server;
|
|
private db: DatabaseAdapter | null = null;
|
|
private repository: NodeRepository | null = null;
|
|
private templateService: TemplateService | null = null;
|
|
private initialized: Promise<void>;
|
|
private cache = new SimpleCache();
|
|
|
|
constructor() {
|
|
// Try multiple database paths
|
|
const possiblePaths = [
|
|
path.join(process.cwd(), 'data', 'nodes.db'),
|
|
path.join(__dirname, '../../data', 'nodes.db'),
|
|
'./data/nodes.db'
|
|
];
|
|
|
|
let dbPath: string | null = null;
|
|
for (const p of possiblePaths) {
|
|
if (existsSync(p)) {
|
|
dbPath = p;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!dbPath) {
|
|
logger.error('Database not found in any of the expected locations:', possiblePaths);
|
|
throw new Error('Database nodes.db not found. Please run npm run rebuild first.');
|
|
}
|
|
|
|
// Initialize database asynchronously
|
|
this.initialized = this.initializeDatabase(dbPath);
|
|
|
|
logger.info('Initializing n8n Documentation MCP server');
|
|
|
|
// Log n8n API configuration status at startup
|
|
const apiConfigured = isN8nApiConfigured();
|
|
const totalTools = apiConfigured ?
|
|
n8nDocumentationToolsFinal.length + n8nManagementTools.length :
|
|
n8nDocumentationToolsFinal.length;
|
|
|
|
logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`);
|
|
|
|
this.server = new Server(
|
|
{
|
|
name: 'n8n-documentation-mcp',
|
|
version: '1.0.0',
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
}
|
|
);
|
|
|
|
this.setupHandlers();
|
|
}
|
|
|
|
private async initializeDatabase(dbPath: string): Promise<void> {
|
|
try {
|
|
this.db = await createDatabaseAdapter(dbPath);
|
|
this.repository = new NodeRepository(this.db);
|
|
this.templateService = new TemplateService(this.db);
|
|
logger.info(`Initialized database from: ${dbPath}`);
|
|
} catch (error) {
|
|
logger.error('Failed to initialize database:', error);
|
|
throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
private async ensureInitialized(): Promise<void> {
|
|
await this.initialized;
|
|
if (!this.db || !this.repository) {
|
|
throw new Error('Database not initialized');
|
|
}
|
|
}
|
|
|
|
private setupHandlers(): void {
|
|
// Handle initialization
|
|
this.server.setRequestHandler(InitializeRequestSchema, async () => {
|
|
const response = {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
serverInfo: {
|
|
name: 'n8n-documentation-mcp',
|
|
version: PROJECT_VERSION,
|
|
},
|
|
};
|
|
|
|
// Debug logging
|
|
if (process.env.DEBUG_MCP === 'true') {
|
|
logger.debug('Initialize handler called', { response });
|
|
}
|
|
|
|
return response;
|
|
});
|
|
|
|
// Handle tool listing
|
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
// Combine documentation tools with management tools if API is configured
|
|
const tools = [...n8nDocumentationToolsFinal];
|
|
const isConfigured = isN8nApiConfigured();
|
|
|
|
if (isConfigured) {
|
|
tools.push(...n8nManagementTools);
|
|
logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`);
|
|
} else {
|
|
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`);
|
|
}
|
|
|
|
return { tools };
|
|
});
|
|
|
|
// Handle tool execution
|
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
try {
|
|
logger.debug(`Executing tool: ${name}`, { args });
|
|
const result = await this.executeTool(name, args);
|
|
logger.debug(`Tool ${name} executed successfully`);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(result, null, 2),
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error executing tool ${name}`, error);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Error executing tool ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
async executeTool(name: string, args: any): Promise<any> {
|
|
switch (name) {
|
|
case 'tools_documentation':
|
|
return this.getToolsDocumentation(args.topic, args.depth);
|
|
case 'list_nodes':
|
|
return this.listNodes(args);
|
|
case 'get_node_info':
|
|
return this.getNodeInfo(args.nodeType);
|
|
case 'search_nodes':
|
|
return this.searchNodes(args.query, args.limit, { mode: args.mode });
|
|
case 'list_ai_tools':
|
|
return this.listAITools();
|
|
case 'get_node_documentation':
|
|
return this.getNodeDocumentation(args.nodeType);
|
|
case 'get_database_statistics':
|
|
return this.getDatabaseStatistics();
|
|
case 'get_node_essentials':
|
|
return this.getNodeEssentials(args.nodeType);
|
|
case 'search_node_properties':
|
|
return this.searchNodeProperties(args.nodeType, args.query, args.maxResults);
|
|
case 'get_node_for_task':
|
|
return this.getNodeForTask(args.task);
|
|
case 'list_tasks':
|
|
return this.listTasks(args.category);
|
|
case 'validate_node_operation':
|
|
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
|
|
case 'validate_node_minimal':
|
|
return this.validateNodeMinimal(args.nodeType, args.config);
|
|
case 'get_property_dependencies':
|
|
return this.getPropertyDependencies(args.nodeType, args.config);
|
|
case 'get_node_as_tool_info':
|
|
return this.getNodeAsToolInfo(args.nodeType);
|
|
case 'list_node_templates':
|
|
return this.listNodeTemplates(args.nodeTypes, args.limit);
|
|
case 'get_template':
|
|
return this.getTemplate(args.templateId);
|
|
case 'search_templates':
|
|
return this.searchTemplates(args.query, args.limit);
|
|
case 'get_templates_for_task':
|
|
return this.getTemplatesForTask(args.task);
|
|
case 'validate_workflow':
|
|
return this.validateWorkflow(args.workflow, args.options);
|
|
case 'validate_workflow_connections':
|
|
return this.validateWorkflowConnections(args.workflow);
|
|
case 'validate_workflow_expressions':
|
|
return this.validateWorkflowExpressions(args.workflow);
|
|
|
|
// n8n Management Tools (if API is configured)
|
|
case 'n8n_create_workflow':
|
|
return n8nHandlers.handleCreateWorkflow(args);
|
|
case 'n8n_get_workflow':
|
|
return n8nHandlers.handleGetWorkflow(args);
|
|
case 'n8n_get_workflow_details':
|
|
return n8nHandlers.handleGetWorkflowDetails(args);
|
|
case 'n8n_get_workflow_structure':
|
|
return n8nHandlers.handleGetWorkflowStructure(args);
|
|
case 'n8n_get_workflow_minimal':
|
|
return n8nHandlers.handleGetWorkflowMinimal(args);
|
|
case 'n8n_update_full_workflow':
|
|
return n8nHandlers.handleUpdateWorkflow(args);
|
|
case 'n8n_update_partial_workflow':
|
|
return handleUpdatePartialWorkflow(args);
|
|
case 'n8n_delete_workflow':
|
|
return n8nHandlers.handleDeleteWorkflow(args);
|
|
case 'n8n_list_workflows':
|
|
return n8nHandlers.handleListWorkflows(args);
|
|
case 'n8n_validate_workflow':
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
return n8nHandlers.handleValidateWorkflow(args, this.repository);
|
|
case 'n8n_trigger_webhook_workflow':
|
|
return n8nHandlers.handleTriggerWebhookWorkflow(args);
|
|
case 'n8n_get_execution':
|
|
return n8nHandlers.handleGetExecution(args);
|
|
case 'n8n_list_executions':
|
|
return n8nHandlers.handleListExecutions(args);
|
|
case 'n8n_delete_execution':
|
|
return n8nHandlers.handleDeleteExecution(args);
|
|
case 'n8n_health_check':
|
|
return n8nHandlers.handleHealthCheck();
|
|
case 'n8n_list_available_tools':
|
|
return n8nHandlers.handleListAvailableTools();
|
|
case 'n8n_diagnostic':
|
|
return n8nHandlers.handleDiagnostic({ params: { arguments: args } });
|
|
|
|
default:
|
|
throw new Error(`Unknown tool: ${name}`);
|
|
}
|
|
}
|
|
|
|
private async listNodes(filters: any = {}): Promise<any> {
|
|
await this.ensureInitialized();
|
|
|
|
let query = 'SELECT * FROM nodes WHERE 1=1';
|
|
const params: any[] = [];
|
|
|
|
// console.log('DEBUG list_nodes:', { filters, query, params }); // Removed to prevent stdout interference
|
|
|
|
if (filters.package) {
|
|
// Handle both formats
|
|
const packageVariants = [
|
|
filters.package,
|
|
`@n8n/${filters.package}`,
|
|
filters.package.replace('@n8n/', '')
|
|
];
|
|
query += ' AND package_name IN (' + packageVariants.map(() => '?').join(',') + ')';
|
|
params.push(...packageVariants);
|
|
}
|
|
|
|
if (filters.category) {
|
|
query += ' AND category = ?';
|
|
params.push(filters.category);
|
|
}
|
|
|
|
if (filters.developmentStyle) {
|
|
query += ' AND development_style = ?';
|
|
params.push(filters.developmentStyle);
|
|
}
|
|
|
|
if (filters.isAITool !== undefined) {
|
|
query += ' AND is_ai_tool = ?';
|
|
params.push(filters.isAITool ? 1 : 0);
|
|
}
|
|
|
|
query += ' ORDER BY display_name';
|
|
|
|
if (filters.limit) {
|
|
query += ' LIMIT ?';
|
|
params.push(filters.limit);
|
|
}
|
|
|
|
const nodes = this.db!.prepare(query).all(...params) as NodeRow[];
|
|
|
|
return {
|
|
nodes: nodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name,
|
|
developmentStyle: node.development_style,
|
|
isAITool: Number(node.is_ai_tool) === 1,
|
|
isTrigger: Number(node.is_trigger) === 1,
|
|
isVersioned: Number(node.is_versioned) === 1,
|
|
})),
|
|
totalCount: nodes.length,
|
|
};
|
|
}
|
|
|
|
private async getNodeInfo(nodeType: string): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Add AI tool capabilities information
|
|
const aiToolCapabilities = {
|
|
canBeUsedAsTool: true, // Any node can be used as a tool in n8n
|
|
hasUsableAsToolProperty: node.isAITool,
|
|
requiresEnvironmentVariable: !node.isAITool && node.package !== 'n8n-nodes-base',
|
|
toolConnectionType: 'ai_tool',
|
|
commonToolUseCases: this.getCommonAIToolUseCases(node.nodeType),
|
|
environmentRequirement: node.package !== 'n8n-nodes-base' ?
|
|
'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true' :
|
|
null
|
|
};
|
|
|
|
return {
|
|
...node,
|
|
aiToolCapabilities
|
|
};
|
|
}
|
|
|
|
private async searchNodes(
|
|
query: string,
|
|
limit: number = 20,
|
|
options?: {
|
|
mode?: 'OR' | 'AND' | 'FUZZY';
|
|
includeSource?: boolean;
|
|
}
|
|
): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
// Normalize the query if it looks like a full node type
|
|
let normalizedQuery = query;
|
|
|
|
// Check if query contains node type patterns and normalize them
|
|
if (query.includes('n8n-nodes-base.') || query.includes('@n8n/n8n-nodes-langchain.')) {
|
|
normalizedQuery = query
|
|
.replace(/n8n-nodes-base\./g, 'nodes-base.')
|
|
.replace(/@n8n\/n8n-nodes-langchain\./g, 'nodes-langchain.');
|
|
}
|
|
|
|
const searchMode = options?.mode || 'OR';
|
|
|
|
// Check if FTS5 table exists
|
|
const ftsExists = this.db.prepare(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type='table' AND name='nodes_fts'
|
|
`).get();
|
|
|
|
if (ftsExists) {
|
|
// Use FTS5 search with normalized query
|
|
return this.searchNodesFTS(normalizedQuery, limit, searchMode);
|
|
} else {
|
|
// Fallback to LIKE search with normalized query
|
|
return this.searchNodesLIKE(normalizedQuery, limit);
|
|
}
|
|
}
|
|
|
|
private async searchNodesFTS(query: string, limit: number, mode: 'OR' | 'AND' | 'FUZZY'): Promise<any> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
// Clean and prepare the query
|
|
const cleanedQuery = query.trim();
|
|
if (!cleanedQuery) {
|
|
return { query, results: [], totalCount: 0 };
|
|
}
|
|
|
|
// For FUZZY mode, use LIKE search with typo patterns
|
|
if (mode === 'FUZZY') {
|
|
return this.searchNodesFuzzy(cleanedQuery, limit);
|
|
}
|
|
|
|
let ftsQuery: string;
|
|
|
|
// Handle exact phrase searches with quotes
|
|
if (cleanedQuery.startsWith('"') && cleanedQuery.endsWith('"')) {
|
|
// Keep exact phrase as is for FTS5
|
|
ftsQuery = cleanedQuery;
|
|
} else {
|
|
// Split into words and handle based on mode
|
|
const words = cleanedQuery.split(/\s+/).filter(w => w.length > 0);
|
|
|
|
switch (mode) {
|
|
case 'AND':
|
|
// All words must be present
|
|
ftsQuery = words.join(' AND ');
|
|
break;
|
|
|
|
case 'OR':
|
|
default:
|
|
// Any word can match (default)
|
|
ftsQuery = words.join(' OR ');
|
|
break;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Use FTS5 with ranking
|
|
const nodes = this.db.prepare(`
|
|
SELECT
|
|
n.*,
|
|
rank
|
|
FROM nodes n
|
|
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
|
|
WHERE nodes_fts MATCH ?
|
|
ORDER BY
|
|
rank,
|
|
CASE
|
|
WHEN n.display_name = ? THEN 0
|
|
WHEN n.display_name LIKE ? THEN 1
|
|
WHEN n.node_type LIKE ? THEN 2
|
|
ELSE 3
|
|
END,
|
|
n.display_name
|
|
LIMIT ?
|
|
`).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit) as (NodeRow & { rank: number })[];
|
|
|
|
// Apply additional relevance scoring for better results
|
|
const scoredNodes = nodes.map(node => {
|
|
const relevanceScore = this.calculateRelevanceScore(node, cleanedQuery);
|
|
return { ...node, relevanceScore };
|
|
});
|
|
|
|
// Sort by combined score (FTS rank + relevance score)
|
|
scoredNodes.sort((a, b) => {
|
|
// Prioritize exact matches
|
|
if (a.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return -1;
|
|
if (b.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return 1;
|
|
|
|
// Then by relevance score
|
|
if (a.relevanceScore !== b.relevanceScore) {
|
|
return b.relevanceScore - a.relevanceScore;
|
|
}
|
|
|
|
// Then by FTS rank
|
|
return a.rank - b.rank;
|
|
});
|
|
|
|
// If FTS didn't find key primary nodes, augment with LIKE search
|
|
const hasHttpRequest = scoredNodes.some(n => n.node_type === 'nodes-base.httpRequest');
|
|
if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) {
|
|
// FTS missed HTTP Request, fall back to LIKE search
|
|
logger.debug('FTS missed HTTP Request node, augmenting with LIKE search');
|
|
return this.searchNodesLIKE(query, limit);
|
|
}
|
|
|
|
const result: any = {
|
|
query,
|
|
results: scoredNodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name,
|
|
relevance: this.calculateRelevance(node, cleanedQuery)
|
|
})),
|
|
totalCount: scoredNodes.length
|
|
};
|
|
|
|
// Only include mode if it's not the default
|
|
if (mode !== 'OR') {
|
|
result.mode = mode;
|
|
}
|
|
|
|
return result;
|
|
|
|
} catch (error: any) {
|
|
// If FTS5 query fails, fallback to LIKE search
|
|
logger.warn('FTS5 search failed, falling back to LIKE search:', error.message);
|
|
|
|
// Special handling for syntax errors
|
|
if (error.message.includes('syntax error') || error.message.includes('fts5')) {
|
|
logger.warn(`FTS5 syntax error for query "${query}" in mode ${mode}`);
|
|
|
|
// For problematic queries, use LIKE search with mode info
|
|
const likeResult = await this.searchNodesLIKE(query, limit);
|
|
return {
|
|
...likeResult,
|
|
mode
|
|
};
|
|
}
|
|
|
|
return this.searchNodesLIKE(query, limit);
|
|
}
|
|
}
|
|
|
|
private async searchNodesFuzzy(query: string, limit: number): Promise<any> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
// Split into words for fuzzy matching
|
|
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
|
|
if (words.length === 0) {
|
|
return { query, results: [], totalCount: 0, mode: 'FUZZY' };
|
|
}
|
|
|
|
// For fuzzy search, get ALL nodes to ensure we don't miss potential matches
|
|
// We'll limit results after scoring
|
|
const candidateNodes = this.db!.prepare(`
|
|
SELECT * FROM nodes
|
|
`).all() as NodeRow[];
|
|
|
|
// Calculate fuzzy scores for candidate nodes
|
|
const scoredNodes = candidateNodes.map(node => {
|
|
const score = this.calculateFuzzyScore(node, query);
|
|
return { node, score };
|
|
});
|
|
|
|
// Filter and sort by score
|
|
const matchingNodes = scoredNodes
|
|
.filter(item => item.score >= 200) // Lower threshold for better typo tolerance
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, limit)
|
|
.map(item => item.node);
|
|
|
|
// Debug logging
|
|
if (matchingNodes.length === 0) {
|
|
const topScores = scoredNodes
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 5);
|
|
logger.debug(`FUZZY search for "${query}" - no matches above 400. Top scores:`,
|
|
topScores.map(s => ({ name: s.node.display_name, score: s.score })));
|
|
}
|
|
|
|
return {
|
|
query,
|
|
mode: 'FUZZY',
|
|
results: matchingNodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name
|
|
})),
|
|
totalCount: matchingNodes.length
|
|
};
|
|
}
|
|
|
|
private calculateFuzzyScore(node: NodeRow, query: string): number {
|
|
const queryLower = query.toLowerCase();
|
|
const displayNameLower = node.display_name.toLowerCase();
|
|
const nodeTypeLower = node.node_type.toLowerCase();
|
|
const nodeTypeClean = nodeTypeLower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
|
|
|
|
// Exact match gets highest score
|
|
if (displayNameLower === queryLower || nodeTypeClean === queryLower) {
|
|
return 1000;
|
|
}
|
|
|
|
// Calculate edit distances for different parts
|
|
const nameDistance = this.getEditDistance(queryLower, displayNameLower);
|
|
const typeDistance = this.getEditDistance(queryLower, nodeTypeClean);
|
|
|
|
// Also check individual words in the display name
|
|
const nameWords = displayNameLower.split(/\s+/);
|
|
let minWordDistance = Infinity;
|
|
for (const word of nameWords) {
|
|
const distance = this.getEditDistance(queryLower, word);
|
|
if (distance < minWordDistance) {
|
|
minWordDistance = distance;
|
|
}
|
|
}
|
|
|
|
// Calculate best match score
|
|
const bestDistance = Math.min(nameDistance, typeDistance, minWordDistance);
|
|
|
|
// Use the length of the matched word for similarity calculation
|
|
let matchedLen = queryLower.length;
|
|
if (minWordDistance === bestDistance) {
|
|
// Find which word matched best
|
|
for (const word of nameWords) {
|
|
if (this.getEditDistance(queryLower, word) === minWordDistance) {
|
|
matchedLen = Math.max(queryLower.length, word.length);
|
|
break;
|
|
}
|
|
}
|
|
} else if (typeDistance === bestDistance) {
|
|
matchedLen = Math.max(queryLower.length, nodeTypeClean.length);
|
|
} else {
|
|
matchedLen = Math.max(queryLower.length, displayNameLower.length);
|
|
}
|
|
|
|
const similarity = 1 - (bestDistance / matchedLen);
|
|
|
|
// Boost if query is a substring
|
|
if (displayNameLower.includes(queryLower) || nodeTypeClean.includes(queryLower)) {
|
|
return 800 + (similarity * 100);
|
|
}
|
|
|
|
// Check if it's a prefix match
|
|
if (displayNameLower.startsWith(queryLower) ||
|
|
nodeTypeClean.startsWith(queryLower) ||
|
|
nameWords.some(w => w.startsWith(queryLower))) {
|
|
return 700 + (similarity * 100);
|
|
}
|
|
|
|
// Allow up to 1-2 character differences for typos
|
|
if (bestDistance <= 2) {
|
|
return 500 + ((2 - bestDistance) * 100) + (similarity * 50);
|
|
}
|
|
|
|
// Allow up to 3 character differences for longer words
|
|
if (bestDistance <= 3 && queryLower.length >= 4) {
|
|
return 400 + ((3 - bestDistance) * 50) + (similarity * 50);
|
|
}
|
|
|
|
// Base score on similarity
|
|
return similarity * 300;
|
|
}
|
|
|
|
private getEditDistance(s1: string, s2: string): number {
|
|
// Simple Levenshtein distance implementation
|
|
const m = s1.length;
|
|
const n = s2.length;
|
|
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
|
|
|
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
if (s1[i - 1] === s2[j - 1]) {
|
|
dp[i][j] = dp[i - 1][j - 1];
|
|
} else {
|
|
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return dp[m][n];
|
|
}
|
|
|
|
private async searchNodesLIKE(query: string, limit: number): Promise<any> {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
// This is the existing LIKE-based implementation
|
|
// Handle exact phrase searches with quotes
|
|
if (query.startsWith('"') && query.endsWith('"')) {
|
|
const exactPhrase = query.slice(1, -1);
|
|
const nodes = this.db!.prepare(`
|
|
SELECT * FROM nodes
|
|
WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
|
|
LIMIT ?
|
|
`).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3) as NodeRow[];
|
|
|
|
// Apply relevance ranking for exact phrase search
|
|
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
|
|
|
|
return {
|
|
query,
|
|
results: rankedNodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name
|
|
})),
|
|
totalCount: rankedNodes.length
|
|
};
|
|
}
|
|
|
|
// Split into words for normal search
|
|
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
|
|
|
if (words.length === 0) {
|
|
return { query, results: [], totalCount: 0 };
|
|
}
|
|
|
|
// Build conditions for each word
|
|
const conditions = words.map(() =>
|
|
'(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)'
|
|
).join(' OR ');
|
|
|
|
const params: any[] = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]);
|
|
// Fetch more results initially to ensure we get the best matches after ranking
|
|
params.push(limit * 3);
|
|
|
|
const nodes = this.db!.prepare(`
|
|
SELECT DISTINCT * FROM nodes
|
|
WHERE ${conditions}
|
|
LIMIT ?
|
|
`).all(...params) as NodeRow[];
|
|
|
|
// Apply relevance ranking
|
|
const rankedNodes = this.rankSearchResults(nodes, query, limit);
|
|
|
|
return {
|
|
query,
|
|
results: rankedNodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name
|
|
})),
|
|
totalCount: rankedNodes.length
|
|
};
|
|
}
|
|
|
|
private calculateRelevance(node: NodeRow, query: string): string {
|
|
const lowerQuery = query.toLowerCase();
|
|
if (node.node_type.toLowerCase().includes(lowerQuery)) return 'high';
|
|
if (node.display_name.toLowerCase().includes(lowerQuery)) return 'high';
|
|
if (node.description?.toLowerCase().includes(lowerQuery)) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
private calculateRelevanceScore(node: NodeRow, query: string): number {
|
|
const query_lower = query.toLowerCase();
|
|
const name_lower = node.display_name.toLowerCase();
|
|
const type_lower = node.node_type.toLowerCase();
|
|
const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
|
|
|
|
let score = 0;
|
|
|
|
// Exact match in display name (highest priority)
|
|
if (name_lower === query_lower) {
|
|
score = 1000;
|
|
}
|
|
// Exact match in node type (without prefix)
|
|
else if (type_without_prefix === query_lower) {
|
|
score = 950;
|
|
}
|
|
// Special boost for common primary nodes
|
|
else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') {
|
|
score = 900;
|
|
}
|
|
else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') {
|
|
score = 900;
|
|
}
|
|
// Additional boost for multi-word queries matching primary nodes
|
|
else if (query_lower.includes('http') && query_lower.includes('call') && node.node_type === 'nodes-base.httpRequest') {
|
|
score = 890;
|
|
}
|
|
else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') {
|
|
score = 850;
|
|
}
|
|
// Boost for webhook queries
|
|
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
|
|
score = 850;
|
|
}
|
|
// Display name starts with query
|
|
else if (name_lower.startsWith(query_lower)) {
|
|
score = 800;
|
|
}
|
|
// Word boundary match in display name
|
|
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
|
|
score = 700;
|
|
}
|
|
// Contains in display name
|
|
else if (name_lower.includes(query_lower)) {
|
|
score = 600;
|
|
}
|
|
// Type contains query (without prefix)
|
|
else if (type_without_prefix.includes(query_lower)) {
|
|
score = 500;
|
|
}
|
|
// Contains in description
|
|
else if (node.description?.toLowerCase().includes(query_lower)) {
|
|
score = 400;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
private rankSearchResults(nodes: NodeRow[], query: string, limit: number): NodeRow[] {
|
|
const query_lower = query.toLowerCase();
|
|
|
|
// Calculate relevance scores for each node
|
|
const scoredNodes = nodes.map(node => {
|
|
const name_lower = node.display_name.toLowerCase();
|
|
const type_lower = node.node_type.toLowerCase();
|
|
const type_without_prefix = type_lower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, '');
|
|
|
|
let score = 0;
|
|
|
|
// Exact match in display name (highest priority)
|
|
if (name_lower === query_lower) {
|
|
score = 1000;
|
|
}
|
|
// Exact match in node type (without prefix)
|
|
else if (type_without_prefix === query_lower) {
|
|
score = 950;
|
|
}
|
|
// Special boost for common primary nodes
|
|
else if (query_lower === 'webhook' && node.node_type === 'nodes-base.webhook') {
|
|
score = 900;
|
|
}
|
|
else if ((query_lower === 'http' || query_lower === 'http request' || query_lower === 'http call') && node.node_type === 'nodes-base.httpRequest') {
|
|
score = 900;
|
|
}
|
|
// Boost for webhook queries
|
|
else if (query_lower.includes('webhook') && node.node_type === 'nodes-base.webhook') {
|
|
score = 850;
|
|
}
|
|
// Additional boost for http queries
|
|
else if (query_lower.includes('http') && node.node_type === 'nodes-base.httpRequest') {
|
|
score = 850;
|
|
}
|
|
// Display name starts with query
|
|
else if (name_lower.startsWith(query_lower)) {
|
|
score = 800;
|
|
}
|
|
// Word boundary match in display name
|
|
else if (new RegExp(`\\b${query_lower}\\b`, 'i').test(node.display_name)) {
|
|
score = 700;
|
|
}
|
|
// Contains in display name
|
|
else if (name_lower.includes(query_lower)) {
|
|
score = 600;
|
|
}
|
|
// Type contains query (without prefix)
|
|
else if (type_without_prefix.includes(query_lower)) {
|
|
score = 500;
|
|
}
|
|
// Contains in description
|
|
else if (node.description?.toLowerCase().includes(query_lower)) {
|
|
score = 400;
|
|
}
|
|
|
|
// For multi-word queries, check if all words are present
|
|
const words = query_lower.split(/\s+/).filter(w => w.length > 0);
|
|
if (words.length > 1) {
|
|
const allWordsInName = words.every(word => name_lower.includes(word));
|
|
const allWordsInDesc = words.every(word => node.description?.toLowerCase().includes(word));
|
|
|
|
if (allWordsInName) score += 200;
|
|
else if (allWordsInDesc) score += 100;
|
|
|
|
// Special handling for common multi-word queries
|
|
if (query_lower === 'http call' && name_lower === 'http request') {
|
|
score = 920; // Boost HTTP Request for "http call" query
|
|
}
|
|
}
|
|
|
|
return { node, score };
|
|
});
|
|
|
|
// Sort by score (descending) and then by display name (ascending)
|
|
scoredNodes.sort((a, b) => {
|
|
if (a.score !== b.score) {
|
|
return b.score - a.score;
|
|
}
|
|
return a.node.display_name.localeCompare(b.node.display_name);
|
|
});
|
|
|
|
// Return only the requested number of results
|
|
return scoredNodes.slice(0, limit).map(item => item.node);
|
|
}
|
|
|
|
private async listAITools(): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
const tools = this.repository.getAITools();
|
|
|
|
// Debug: Check if is_ai_tool column is populated
|
|
const aiCount = this.db!.prepare('SELECT COUNT(*) as ai_count FROM nodes WHERE is_ai_tool = 1').get() as any;
|
|
// console.log('DEBUG list_ai_tools:', {
|
|
// toolsLength: tools.length,
|
|
// aiCountInDB: aiCount.ai_count,
|
|
// sampleTools: tools.slice(0, 3)
|
|
// }); // Removed to prevent stdout interference
|
|
|
|
return {
|
|
tools,
|
|
totalCount: tools.length,
|
|
requirements: {
|
|
environmentVariable: 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true',
|
|
nodeProperty: 'usableAsTool: true',
|
|
},
|
|
usage: {
|
|
description: 'These nodes have the usableAsTool property set to true, making them optimized for AI agent usage.',
|
|
note: 'ANY node in n8n can be used as an AI tool by connecting it to the ai_tool port of an AI Agent node.',
|
|
examples: [
|
|
'Regular nodes like Slack, Google Sheets, or HTTP Request can be used as tools',
|
|
'Connect any node to an AI Agent\'s tool port to make it available for AI-driven automation',
|
|
'Community nodes require the environment variable to be set'
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
private async getNodeDocumentation(nodeType: string): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.db!.prepare(`
|
|
SELECT node_type, display_name, documentation, description
|
|
FROM nodes
|
|
WHERE node_type = ?
|
|
`).get(normalizedType) as NodeRow | undefined;
|
|
|
|
// If not found and normalization changed the type, try original
|
|
if (!node && normalizedType !== nodeType) {
|
|
node = this.db!.prepare(`
|
|
SELECT node_type, display_name, documentation, description
|
|
FROM nodes
|
|
WHERE node_type = ?
|
|
`).get(nodeType) as NodeRow | undefined;
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// If no documentation, generate fallback
|
|
if (!node.documentation) {
|
|
const essentials = await this.getNodeEssentials(nodeType);
|
|
|
|
return {
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
documentation: `
|
|
# ${node.display_name}
|
|
|
|
${node.description || 'No description available.'}
|
|
|
|
## Common Properties
|
|
|
|
${essentials.commonProperties.map((p: any) =>
|
|
`### ${p.displayName}\n${p.description || `Type: ${p.type}`}`
|
|
).join('\n\n')}
|
|
|
|
## Note
|
|
Full documentation is being prepared. For now, use get_node_essentials for configuration help.
|
|
`,
|
|
hasDocumentation: false
|
|
};
|
|
}
|
|
|
|
return {
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
documentation: node.documentation,
|
|
hasDocumentation: true,
|
|
};
|
|
}
|
|
|
|
private async getDatabaseStatistics(): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
const stats = this.db!.prepare(`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(is_ai_tool) as ai_tools,
|
|
SUM(is_trigger) as triggers,
|
|
SUM(is_versioned) as versioned,
|
|
SUM(CASE WHEN documentation IS NOT NULL THEN 1 ELSE 0 END) as with_docs,
|
|
COUNT(DISTINCT package_name) as packages,
|
|
COUNT(DISTINCT category) as categories
|
|
FROM nodes
|
|
`).get() as any;
|
|
|
|
const packages = this.db!.prepare(`
|
|
SELECT package_name, COUNT(*) as count
|
|
FROM nodes
|
|
GROUP BY package_name
|
|
`).all() as any[];
|
|
|
|
return {
|
|
totalNodes: stats.total,
|
|
statistics: {
|
|
aiTools: stats.ai_tools,
|
|
triggers: stats.triggers,
|
|
versionedNodes: stats.versioned,
|
|
nodesWithDocumentation: stats.with_docs,
|
|
documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%',
|
|
uniquePackages: stats.packages,
|
|
uniqueCategories: stats.categories,
|
|
},
|
|
packageBreakdown: packages.map(pkg => ({
|
|
package: pkg.package_name,
|
|
nodeCount: pkg.count,
|
|
})),
|
|
};
|
|
}
|
|
|
|
private async getNodeEssentials(nodeType: string): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Check cache first
|
|
const cacheKey = `essentials:${nodeType}`;
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
// Get the full node information
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Get properties (already parsed by repository)
|
|
const allProperties = node.properties || [];
|
|
|
|
// Get essential properties
|
|
const essentials = PropertyFilter.getEssentials(allProperties, node.nodeType);
|
|
|
|
// Get operations (already parsed by repository)
|
|
const operations = node.operations || [];
|
|
|
|
const result = {
|
|
nodeType: node.nodeType,
|
|
displayName: node.displayName,
|
|
description: node.description,
|
|
category: node.category,
|
|
version: node.version || '1',
|
|
isVersioned: node.isVersioned || false,
|
|
requiredProperties: essentials.required,
|
|
commonProperties: essentials.common,
|
|
operations: operations.map((op: any) => ({
|
|
name: op.name || op.operation,
|
|
description: op.description,
|
|
action: op.action,
|
|
resource: op.resource
|
|
})),
|
|
// Examples removed - use validate_node_operation for working configurations
|
|
metadata: {
|
|
totalProperties: allProperties.length,
|
|
isAITool: node.isAITool,
|
|
isTrigger: node.isTrigger,
|
|
isWebhook: node.isWebhook,
|
|
hasCredentials: node.credentials ? true : false,
|
|
package: node.package,
|
|
developmentStyle: node.developmentStyle || 'programmatic'
|
|
}
|
|
};
|
|
|
|
// Cache for 1 hour
|
|
this.cache.set(cacheKey, result, 3600);
|
|
|
|
return result;
|
|
}
|
|
|
|
private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Get the node
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Get properties and search (already parsed by repository)
|
|
const allProperties = node.properties || [];
|
|
const matches = PropertyFilter.searchProperties(allProperties, query, maxResults);
|
|
|
|
return {
|
|
nodeType: node.nodeType,
|
|
query,
|
|
matches: matches.map((match: any) => ({
|
|
name: match.name,
|
|
displayName: match.displayName,
|
|
type: match.type,
|
|
description: match.description,
|
|
path: match.path || match.name,
|
|
required: match.required,
|
|
default: match.default,
|
|
options: match.options,
|
|
showWhen: match.showWhen
|
|
})),
|
|
totalMatches: matches.length,
|
|
searchedIn: allProperties.length + ' properties'
|
|
};
|
|
}
|
|
|
|
private async getNodeForTask(task: string): Promise<any> {
|
|
const template = TaskTemplates.getTaskTemplate(task);
|
|
|
|
if (!template) {
|
|
// Try to find similar tasks
|
|
const similar = TaskTemplates.searchTasks(task);
|
|
throw new Error(
|
|
`Unknown task: ${task}. ` +
|
|
(similar.length > 0
|
|
? `Did you mean: ${similar.slice(0, 3).join(', ')}?`
|
|
: `Use 'list_tasks' to see available tasks.`)
|
|
);
|
|
}
|
|
|
|
return {
|
|
task: template.task,
|
|
description: template.description,
|
|
nodeType: template.nodeType,
|
|
configuration: template.configuration,
|
|
userMustProvide: template.userMustProvide,
|
|
optionalEnhancements: template.optionalEnhancements || [],
|
|
notes: template.notes || [],
|
|
example: {
|
|
node: {
|
|
type: template.nodeType,
|
|
parameters: template.configuration
|
|
},
|
|
userInputsNeeded: template.userMustProvide.map(p => ({
|
|
property: p.property,
|
|
currentValue: this.getPropertyValue(template.configuration, p.property),
|
|
description: p.description,
|
|
example: p.example
|
|
}))
|
|
}
|
|
};
|
|
}
|
|
|
|
private getPropertyValue(config: any, path: string): any {
|
|
const parts = path.split('.');
|
|
let value = config;
|
|
|
|
for (const part of parts) {
|
|
// Handle array notation like parameters[0]
|
|
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
|
|
if (arrayMatch) {
|
|
value = value?.[arrayMatch[1]]?.[parseInt(arrayMatch[2])];
|
|
} else {
|
|
value = value?.[part];
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
private async listTasks(category?: string): Promise<any> {
|
|
if (category) {
|
|
const categories = TaskTemplates.getTaskCategories();
|
|
const tasks = categories[category];
|
|
|
|
if (!tasks) {
|
|
throw new Error(
|
|
`Unknown category: ${category}. Available categories: ${Object.keys(categories).join(', ')}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
category,
|
|
tasks: tasks.map(task => {
|
|
const template = TaskTemplates.getTaskTemplate(task);
|
|
return {
|
|
task,
|
|
description: template?.description || '',
|
|
nodeType: template?.nodeType || ''
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
// Return all tasks grouped by category
|
|
const categories = TaskTemplates.getTaskCategories();
|
|
const result: any = {
|
|
totalTasks: TaskTemplates.getAllTasks().length,
|
|
categories: {}
|
|
};
|
|
|
|
for (const [cat, tasks] of Object.entries(categories)) {
|
|
result.categories[cat] = tasks.map(task => {
|
|
const template = TaskTemplates.getTaskTemplate(task);
|
|
return {
|
|
task,
|
|
description: template?.description || '',
|
|
nodeType: template?.nodeType || ''
|
|
};
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async validateNodeConfig(
|
|
nodeType: string,
|
|
config: Record<string, any>,
|
|
mode: ValidationMode = 'operation',
|
|
profile: ValidationProfile = 'ai-friendly'
|
|
): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Get node info to access properties
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Get properties
|
|
const properties = node.properties || [];
|
|
|
|
// Use enhanced validator with operation mode by default
|
|
const validationResult = EnhancedConfigValidator.validateWithMode(
|
|
node.nodeType,
|
|
config,
|
|
properties,
|
|
mode,
|
|
profile
|
|
);
|
|
|
|
// Add node context to result
|
|
return {
|
|
nodeType: node.nodeType,
|
|
displayName: node.displayName,
|
|
...validationResult,
|
|
summary: {
|
|
hasErrors: !validationResult.valid,
|
|
errorCount: validationResult.errors.length,
|
|
warningCount: validationResult.warnings.length,
|
|
suggestionCount: validationResult.suggestions.length
|
|
}
|
|
};
|
|
}
|
|
|
|
private async getPropertyDependencies(nodeType: string, config?: Record<string, any>): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Get node info to access properties
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Get properties
|
|
const properties = node.properties || [];
|
|
|
|
// Analyze dependencies
|
|
const analysis = PropertyDependencies.analyze(properties);
|
|
|
|
// If config provided, check visibility impact
|
|
let visibilityImpact = null;
|
|
if (config) {
|
|
visibilityImpact = PropertyDependencies.getVisibilityImpact(properties, config);
|
|
}
|
|
|
|
return {
|
|
nodeType: node.nodeType,
|
|
displayName: node.displayName,
|
|
...analysis,
|
|
currentConfig: config ? {
|
|
providedValues: config,
|
|
visibilityImpact
|
|
} : undefined
|
|
};
|
|
}
|
|
|
|
private async getNodeAsToolInfo(nodeType: string): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Get node info
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Determine common AI tool use cases based on node type
|
|
const commonUseCases = this.getCommonAIToolUseCases(node.nodeType);
|
|
|
|
// Build AI tool capabilities info
|
|
const aiToolCapabilities = {
|
|
canBeUsedAsTool: true, // In n8n, ANY node can be used as a tool when connected to AI Agent
|
|
hasUsableAsToolProperty: node.isAITool,
|
|
requiresEnvironmentVariable: !node.isAITool && node.package !== 'n8n-nodes-base',
|
|
connectionType: 'ai_tool',
|
|
commonUseCases,
|
|
requirements: {
|
|
connection: 'Connect to the "ai_tool" port of an AI Agent node',
|
|
environment: node.package !== 'n8n-nodes-base' ?
|
|
'Set N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true for community nodes' :
|
|
'No special environment variables needed for built-in nodes'
|
|
},
|
|
examples: this.getAIToolExamples(node.nodeType),
|
|
tips: [
|
|
'Give the tool a clear, descriptive name in the AI Agent settings',
|
|
'Write a detailed tool description to help the AI understand when to use it',
|
|
'Test the node independently before connecting it as a tool',
|
|
node.isAITool ?
|
|
'This node is optimized for AI tool usage' :
|
|
'This is a regular node that can be used as an AI tool'
|
|
]
|
|
};
|
|
|
|
return {
|
|
nodeType: node.nodeType,
|
|
displayName: node.displayName,
|
|
description: node.description,
|
|
package: node.package,
|
|
isMarkedAsAITool: node.isAITool,
|
|
aiToolCapabilities
|
|
};
|
|
}
|
|
|
|
private getCommonAIToolUseCases(nodeType: string): string[] {
|
|
const useCaseMap: Record<string, string[]> = {
|
|
'nodes-base.slack': [
|
|
'Send notifications about task completion',
|
|
'Post updates to channels',
|
|
'Send direct messages',
|
|
'Create alerts and reminders'
|
|
],
|
|
'nodes-base.googleSheets': [
|
|
'Read data for analysis',
|
|
'Log results and outputs',
|
|
'Update spreadsheet records',
|
|
'Create reports'
|
|
],
|
|
'nodes-base.gmail': [
|
|
'Send email notifications',
|
|
'Read and process emails',
|
|
'Send reports and summaries',
|
|
'Handle email-based workflows'
|
|
],
|
|
'nodes-base.httpRequest': [
|
|
'Call external APIs',
|
|
'Fetch data from web services',
|
|
'Send webhooks',
|
|
'Integrate with any REST API'
|
|
],
|
|
'nodes-base.postgres': [
|
|
'Query database for information',
|
|
'Store analysis results',
|
|
'Update records based on AI decisions',
|
|
'Generate reports from data'
|
|
],
|
|
'nodes-base.webhook': [
|
|
'Receive external triggers',
|
|
'Create callback endpoints',
|
|
'Handle incoming data',
|
|
'Integrate with external systems'
|
|
]
|
|
};
|
|
|
|
// Check for partial matches
|
|
for (const [key, useCases] of Object.entries(useCaseMap)) {
|
|
if (nodeType.includes(key)) {
|
|
return useCases;
|
|
}
|
|
}
|
|
|
|
// Generic use cases for unknown nodes
|
|
return [
|
|
'Perform automated actions',
|
|
'Integrate with external services',
|
|
'Process and transform data',
|
|
'Extend AI agent capabilities'
|
|
];
|
|
}
|
|
|
|
private getAIToolExamples(nodeType: string): any {
|
|
const exampleMap: Record<string, any> = {
|
|
'nodes-base.slack': {
|
|
toolName: 'Send Slack Message',
|
|
toolDescription: 'Sends a message to a specified Slack channel or user. Use this to notify team members about important events or results.',
|
|
nodeConfig: {
|
|
resource: 'message',
|
|
operation: 'post',
|
|
channel: '={{ $fromAI("channel", "The Slack channel to send to, e.g. #general") }}',
|
|
text: '={{ $fromAI("message", "The message content to send") }}'
|
|
}
|
|
},
|
|
'nodes-base.googleSheets': {
|
|
toolName: 'Update Google Sheet',
|
|
toolDescription: 'Reads or updates data in a Google Sheets spreadsheet. Use this to log information, retrieve data, or update records.',
|
|
nodeConfig: {
|
|
operation: 'append',
|
|
sheetId: 'your-sheet-id',
|
|
range: 'A:Z',
|
|
dataMode: 'autoMap'
|
|
}
|
|
},
|
|
'nodes-base.httpRequest': {
|
|
toolName: 'Call API',
|
|
toolDescription: 'Makes HTTP requests to external APIs. Use this to fetch data, trigger webhooks, or integrate with any web service.',
|
|
nodeConfig: {
|
|
method: '={{ $fromAI("method", "HTTP method: GET, POST, PUT, DELETE") }}',
|
|
url: '={{ $fromAI("url", "The complete API endpoint URL") }}',
|
|
sendBody: true,
|
|
bodyContentType: 'json',
|
|
jsonBody: '={{ $fromAI("body", "Request body as JSON object") }}'
|
|
}
|
|
}
|
|
};
|
|
|
|
// Check for exact match or partial match
|
|
for (const [key, example] of Object.entries(exampleMap)) {
|
|
if (nodeType.includes(key)) {
|
|
return example;
|
|
}
|
|
}
|
|
|
|
// Generic example
|
|
return {
|
|
toolName: 'Custom Tool',
|
|
toolDescription: 'Performs specific operations. Describe what this tool does and when to use it.',
|
|
nodeConfig: {
|
|
note: 'Configure the node based on its specific requirements'
|
|
}
|
|
};
|
|
}
|
|
|
|
private async validateNodeMinimal(nodeType: string, config: Record<string, any>): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Get node info
|
|
// First try with normalized type
|
|
const normalizedType = normalizeNodeType(nodeType);
|
|
let node = this.repository.getNode(normalizedType);
|
|
|
|
if (!node && normalizedType !== nodeType) {
|
|
// Try original if normalization changed it
|
|
node = this.repository.getNode(nodeType);
|
|
}
|
|
|
|
if (!node) {
|
|
// Fallback to other alternatives for edge cases
|
|
const alternatives = getNodeTypeAlternatives(nodeType);
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
// Get properties
|
|
const properties = node.properties || [];
|
|
|
|
// Extract operation context
|
|
const operationContext = {
|
|
resource: config.resource,
|
|
operation: config.operation,
|
|
action: config.action,
|
|
mode: config.mode
|
|
};
|
|
|
|
// Find missing required fields
|
|
const missingFields: string[] = [];
|
|
|
|
for (const prop of properties) {
|
|
// Skip if not required
|
|
if (!prop.required) continue;
|
|
|
|
// Skip if not visible based on current config
|
|
if (prop.displayOptions) {
|
|
let isVisible = true;
|
|
|
|
// Check show conditions
|
|
if (prop.displayOptions.show) {
|
|
for (const [key, values] of Object.entries(prop.displayOptions.show)) {
|
|
const configValue = config[key];
|
|
const expectedValues = Array.isArray(values) ? values : [values];
|
|
|
|
if (!expectedValues.includes(configValue)) {
|
|
isVisible = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check hide conditions
|
|
if (isVisible && prop.displayOptions.hide) {
|
|
for (const [key, values] of Object.entries(prop.displayOptions.hide)) {
|
|
const configValue = config[key];
|
|
const expectedValues = Array.isArray(values) ? values : [values];
|
|
|
|
if (expectedValues.includes(configValue)) {
|
|
isVisible = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isVisible) continue;
|
|
}
|
|
|
|
// Check if field is missing
|
|
if (!(prop.name in config)) {
|
|
missingFields.push(prop.displayName || prop.name);
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodeType: node.nodeType,
|
|
displayName: node.displayName,
|
|
valid: missingFields.length === 0,
|
|
missingRequiredFields: missingFields
|
|
};
|
|
}
|
|
|
|
// Method removed - replaced by getToolsDocumentation
|
|
|
|
private async getToolsDocumentation(topic?: string, depth: 'essentials' | 'full' = 'essentials'): Promise<string> {
|
|
if (!topic || topic === 'overview') {
|
|
return getToolsOverview(depth);
|
|
}
|
|
|
|
return getToolDocumentation(topic, depth);
|
|
}
|
|
|
|
// Add connect method to accept any transport
|
|
async connect(transport: any): Promise<void> {
|
|
await this.ensureInitialized();
|
|
await this.server.connect(transport);
|
|
logger.info('MCP Server connected', {
|
|
transportType: transport.constructor.name
|
|
});
|
|
}
|
|
|
|
// Template-related methods
|
|
private async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.templateService) throw new Error('Template service not initialized');
|
|
|
|
const templates = await this.templateService.listNodeTemplates(nodeTypes, limit);
|
|
|
|
if (templates.length === 0) {
|
|
return {
|
|
message: `No templates found using nodes: ${nodeTypes.join(', ')}`,
|
|
tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database",
|
|
templates: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
templates,
|
|
count: templates.length,
|
|
tip: `Use get_template(templateId) to get the full workflow JSON for any template`
|
|
};
|
|
}
|
|
|
|
private async getTemplate(templateId: number): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.templateService) throw new Error('Template service not initialized');
|
|
|
|
const template = await this.templateService.getTemplate(templateId);
|
|
|
|
if (!template) {
|
|
return {
|
|
error: `Template ${templateId} not found`,
|
|
tip: "Use list_node_templates or search_templates to find available templates"
|
|
};
|
|
}
|
|
|
|
return {
|
|
template,
|
|
usage: "Import this workflow JSON directly into n8n or use it as a reference for building workflows"
|
|
};
|
|
}
|
|
|
|
private async searchTemplates(query: string, limit: number = 20): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.templateService) throw new Error('Template service not initialized');
|
|
|
|
const templates = await this.templateService.searchTemplates(query, limit);
|
|
|
|
if (templates.length === 0) {
|
|
return {
|
|
message: `No templates found matching: "${query}"`,
|
|
tip: "Try different keywords or run 'npm run fetch:templates' to update template database",
|
|
templates: []
|
|
};
|
|
}
|
|
|
|
return {
|
|
templates,
|
|
count: templates.length,
|
|
query
|
|
};
|
|
}
|
|
|
|
private async getTemplatesForTask(task: string): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.templateService) throw new Error('Template service not initialized');
|
|
|
|
const templates = await this.templateService.getTemplatesForTask(task);
|
|
const availableTasks = this.templateService.listAvailableTasks();
|
|
|
|
if (templates.length === 0) {
|
|
return {
|
|
message: `No templates found for task: ${task}`,
|
|
availableTasks,
|
|
tip: "Try a different task or use search_templates for custom searches"
|
|
};
|
|
}
|
|
|
|
return {
|
|
task,
|
|
templates,
|
|
count: templates.length,
|
|
description: this.getTaskDescription(task)
|
|
};
|
|
}
|
|
|
|
private getTaskDescription(task: string): string {
|
|
const descriptions: Record<string, string> = {
|
|
'ai_automation': 'AI-powered workflows using OpenAI, LangChain, and other AI tools',
|
|
'data_sync': 'Synchronize data between databases, spreadsheets, and APIs',
|
|
'webhook_processing': 'Process incoming webhooks and trigger automated actions',
|
|
'email_automation': 'Send, receive, and process emails automatically',
|
|
'slack_integration': 'Integrate with Slack for notifications and bot interactions',
|
|
'data_transformation': 'Transform, clean, and manipulate data',
|
|
'file_processing': 'Handle file uploads, downloads, and transformations',
|
|
'scheduling': 'Schedule recurring tasks and time-based automations',
|
|
'api_integration': 'Connect to external APIs and web services',
|
|
'database_operations': 'Query, insert, update, and manage database records'
|
|
};
|
|
|
|
return descriptions[task] || 'Workflow templates for this task';
|
|
}
|
|
|
|
private async validateWorkflow(workflow: any, options?: any): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Create workflow validator instance
|
|
const validator = new WorkflowValidator(
|
|
this.repository,
|
|
EnhancedConfigValidator
|
|
);
|
|
|
|
try {
|
|
const result = await validator.validateWorkflow(workflow, options);
|
|
|
|
// Format the response for better readability
|
|
const response: any = {
|
|
valid: result.valid,
|
|
summary: {
|
|
totalNodes: result.statistics.totalNodes,
|
|
enabledNodes: result.statistics.enabledNodes,
|
|
triggerNodes: result.statistics.triggerNodes,
|
|
validConnections: result.statistics.validConnections,
|
|
invalidConnections: result.statistics.invalidConnections,
|
|
expressionsValidated: result.statistics.expressionsValidated,
|
|
errorCount: result.errors.length,
|
|
warningCount: result.warnings.length
|
|
}
|
|
};
|
|
|
|
if (result.errors.length > 0) {
|
|
response.errors = result.errors.map(e => ({
|
|
node: e.nodeName || 'workflow',
|
|
message: e.message,
|
|
details: e.details
|
|
}));
|
|
}
|
|
|
|
if (result.warnings.length > 0) {
|
|
response.warnings = result.warnings.map(w => ({
|
|
node: w.nodeName || 'workflow',
|
|
message: w.message,
|
|
details: w.details
|
|
}));
|
|
}
|
|
|
|
if (result.suggestions.length > 0) {
|
|
response.suggestions = result.suggestions;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.error('Error validating workflow:', error);
|
|
return {
|
|
valid: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error validating workflow',
|
|
tip: 'Ensure the workflow JSON includes nodes array and connections object'
|
|
};
|
|
}
|
|
}
|
|
|
|
private async validateWorkflowConnections(workflow: any): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Create workflow validator instance
|
|
const validator = new WorkflowValidator(
|
|
this.repository,
|
|
EnhancedConfigValidator
|
|
);
|
|
|
|
try {
|
|
// Validate only connections
|
|
const result = await validator.validateWorkflow(workflow, {
|
|
validateNodes: false,
|
|
validateConnections: true,
|
|
validateExpressions: false
|
|
});
|
|
|
|
const response: any = {
|
|
valid: result.errors.length === 0,
|
|
statistics: {
|
|
totalNodes: result.statistics.totalNodes,
|
|
triggerNodes: result.statistics.triggerNodes,
|
|
validConnections: result.statistics.validConnections,
|
|
invalidConnections: result.statistics.invalidConnections
|
|
}
|
|
};
|
|
|
|
// Filter to only connection-related issues
|
|
const connectionErrors = result.errors.filter(e =>
|
|
e.message.includes('connection') ||
|
|
e.message.includes('cycle') ||
|
|
e.message.includes('orphaned')
|
|
);
|
|
|
|
const connectionWarnings = result.warnings.filter(w =>
|
|
w.message.includes('connection') ||
|
|
w.message.includes('orphaned') ||
|
|
w.message.includes('trigger')
|
|
);
|
|
|
|
if (connectionErrors.length > 0) {
|
|
response.errors = connectionErrors.map(e => ({
|
|
node: e.nodeName || 'workflow',
|
|
message: e.message
|
|
}));
|
|
}
|
|
|
|
if (connectionWarnings.length > 0) {
|
|
response.warnings = connectionWarnings.map(w => ({
|
|
node: w.nodeName || 'workflow',
|
|
message: w.message
|
|
}));
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.error('Error validating workflow connections:', error);
|
|
return {
|
|
valid: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error validating connections'
|
|
};
|
|
}
|
|
}
|
|
|
|
private async validateWorkflowExpressions(workflow: any): Promise<any> {
|
|
await this.ensureInitialized();
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
|
|
// Create workflow validator instance
|
|
const validator = new WorkflowValidator(
|
|
this.repository,
|
|
EnhancedConfigValidator
|
|
);
|
|
|
|
try {
|
|
// Validate only expressions
|
|
const result = await validator.validateWorkflow(workflow, {
|
|
validateNodes: false,
|
|
validateConnections: false,
|
|
validateExpressions: true
|
|
});
|
|
|
|
const response: any = {
|
|
valid: result.errors.length === 0,
|
|
statistics: {
|
|
totalNodes: result.statistics.totalNodes,
|
|
expressionsValidated: result.statistics.expressionsValidated
|
|
}
|
|
};
|
|
|
|
// Filter to only expression-related issues
|
|
const expressionErrors = result.errors.filter(e =>
|
|
e.message.includes('Expression') ||
|
|
e.message.includes('$') ||
|
|
e.message.includes('{{')
|
|
);
|
|
|
|
const expressionWarnings = result.warnings.filter(w =>
|
|
w.message.includes('Expression') ||
|
|
w.message.includes('$') ||
|
|
w.message.includes('{{')
|
|
);
|
|
|
|
if (expressionErrors.length > 0) {
|
|
response.errors = expressionErrors.map(e => ({
|
|
node: e.nodeName || 'workflow',
|
|
message: e.message
|
|
}));
|
|
}
|
|
|
|
if (expressionWarnings.length > 0) {
|
|
response.warnings = expressionWarnings.map(w => ({
|
|
node: w.nodeName || 'workflow',
|
|
message: w.message
|
|
}));
|
|
}
|
|
|
|
// Add tips for common expression issues
|
|
if (expressionErrors.length > 0 || expressionWarnings.length > 0) {
|
|
response.tips = [
|
|
'Use {{ }} to wrap expressions',
|
|
'Reference data with $json.propertyName',
|
|
'Reference other nodes with $node["Node Name"].json',
|
|
'Use $input.item for input data in loops'
|
|
];
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.error('Error validating workflow expressions:', error);
|
|
return {
|
|
valid: false,
|
|
error: error instanceof Error ? error.message : 'Unknown error validating expressions'
|
|
};
|
|
}
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
// Ensure database is initialized before starting server
|
|
await this.ensureInitialized();
|
|
|
|
const transport = new StdioServerTransport();
|
|
await this.server.connect(transport);
|
|
|
|
// Force flush stdout for Docker environments
|
|
// Docker uses block buffering which can delay MCP responses
|
|
if (!process.stdout.isTTY || process.env.IS_DOCKER) {
|
|
// Override write to auto-flush
|
|
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
process.stdout.write = function(chunk: any, encoding?: any, callback?: any) {
|
|
const result = originalWrite(chunk, encoding, callback);
|
|
// Force immediate flush
|
|
process.stdout.emit('drain');
|
|
return result;
|
|
};
|
|
}
|
|
|
|
logger.info('n8n Documentation MCP Server running on stdio transport');
|
|
|
|
// Keep the process alive and listening
|
|
process.stdin.resume();
|
|
}
|
|
} |