Refactor to focused n8n node documentation MCP server
Major refactoring to align with actual requirements: - Purpose: Serve n8n node code/documentation to AI agents only - No workflow execution or management features - Complete node information including source code, docs, and examples New features: - Node documentation service with SQLite FTS5 search - Documentation fetcher from n8n-docs repository - Example workflow generator for each node type - Simplified MCP tools focused on node information - Complete database rebuild with all node data MCP Tools: - list_nodes: List available nodes - get_node_info: Get complete node information - search_nodes: Full-text search across nodes - get_node_example: Get usage examples - get_node_source_code: Get source code only - get_node_documentation: Get documentation only - rebuild_database: Rebuild entire database - get_database_statistics: Database stats Database schema includes: - Node source code and metadata - Official documentation from n8n-docs - Generated usage examples - Full-text search capabilities - Category and type filtering Updated README with: - Clear purpose statement - Claude Desktop installation instructions - Complete tool documentation - Troubleshooting guide 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
99
src/db/schema-v2.sql
Normal file
99
src/db/schema-v2.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Main nodes table with documentation and examples
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_type TEXT UNIQUE NOT NULL, -- e.g., "n8n-nodes-base.if"
|
||||
name TEXT NOT NULL, -- e.g., "If"
|
||||
display_name TEXT, -- e.g., "If"
|
||||
description TEXT, -- Brief description from node definition
|
||||
category TEXT, -- e.g., "Core Nodes", "Flow"
|
||||
subcategory TEXT, -- More specific categorization
|
||||
icon TEXT, -- Icon identifier/path
|
||||
|
||||
-- Source code
|
||||
source_code TEXT NOT NULL, -- Full node source code
|
||||
credential_code TEXT, -- Credential type definitions
|
||||
code_hash TEXT NOT NULL, -- Hash for change detection
|
||||
code_length INTEGER NOT NULL, -- Source code size
|
||||
|
||||
-- Documentation
|
||||
documentation_markdown TEXT, -- Full markdown documentation from n8n-docs
|
||||
documentation_url TEXT, -- URL to documentation page
|
||||
|
||||
-- Example usage
|
||||
example_workflow TEXT, -- JSON example workflow using this node
|
||||
example_parameters TEXT, -- JSON example of node parameters
|
||||
properties_schema TEXT, -- JSON schema of node properties
|
||||
|
||||
-- Metadata
|
||||
package_name TEXT NOT NULL, -- e.g., "n8n-nodes-base"
|
||||
version TEXT, -- Node version
|
||||
codex_data TEXT, -- Additional codex/metadata JSON
|
||||
aliases TEXT, -- JSON array of alternative names
|
||||
|
||||
-- Flags
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
is_trigger INTEGER DEFAULT 0, -- Whether it's a trigger node
|
||||
is_webhook INTEGER DEFAULT 0, -- Whether it's a webhook node
|
||||
|
||||
-- Timestamps
|
||||
extracted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_package_name ON nodes(package_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_category ON nodes(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_code_hash ON nodes(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_is_trigger ON nodes(is_trigger);
|
||||
|
||||
-- Full Text Search virtual table for comprehensive search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
||||
node_type,
|
||||
name,
|
||||
display_name,
|
||||
description,
|
||||
category,
|
||||
documentation_markdown,
|
||||
aliases,
|
||||
content=nodes,
|
||||
content_rowid=id
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS in sync
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes
|
||||
BEGIN
|
||||
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
|
||||
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes
|
||||
BEGIN
|
||||
DELETE FROM nodes_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes
|
||||
BEGIN
|
||||
DELETE FROM nodes_fts WHERE rowid = old.id;
|
||||
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
|
||||
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
|
||||
END;
|
||||
|
||||
-- Table for storing node documentation versions
|
||||
CREATE TABLE IF NOT EXISTS documentation_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL, -- 'n8n-docs-repo', 'inline', 'generated'
|
||||
commit_hash TEXT, -- Git commit hash if from repo
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Statistics table
|
||||
CREATE TABLE IF NOT EXISTS extraction_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
total_nodes INTEGER NOT NULL,
|
||||
nodes_with_docs INTEGER NOT NULL,
|
||||
nodes_with_examples INTEGER NOT NULL,
|
||||
total_code_size INTEGER NOT NULL,
|
||||
total_docs_size INTEGER NOT NULL,
|
||||
extraction_date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
58
src/index-v2.ts
Normal file
58
src/index-v2.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { N8NDocumentationMCPServer } from './mcp/server-v2';
|
||||
import { MCPServerConfig } from './types';
|
||||
import { logger } from './utils/logger';
|
||||
import { NodeDocumentationService } from './services/node-documentation-service';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function main() {
|
||||
const config: MCPServerConfig = {
|
||||
port: parseInt(process.env.MCP_SERVER_PORT || '3000', 10),
|
||||
host: process.env.MCP_SERVER_HOST || 'localhost',
|
||||
authToken: process.env.MCP_AUTH_TOKEN,
|
||||
};
|
||||
|
||||
// Check if we should rebuild the database on startup
|
||||
const rebuildOnStart = process.env.REBUILD_ON_START === 'true';
|
||||
|
||||
if (rebuildOnStart) {
|
||||
logger.info('Rebuilding database on startup...');
|
||||
const service = new NodeDocumentationService();
|
||||
try {
|
||||
const stats = await service.rebuildDatabase();
|
||||
logger.info('Database rebuild complete:', stats);
|
||||
} catch (error) {
|
||||
logger.error('Failed to rebuild database:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
service.close();
|
||||
}
|
||||
}
|
||||
|
||||
const server = new N8NDocumentationMCPServer(config);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
} catch (error) {
|
||||
logger.error('Failed to start MCP server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Received SIGINT, shutting down MCP server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('Received SIGTERM, shutting down MCP server...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
435
src/mcp/server-v2.ts
Normal file
435
src/mcp/server-v2.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { NodeDocumentationService } from '../services/node-documentation-service';
|
||||
import { nodeDocumentationTools } from './tools-v2';
|
||||
import { logger } from '../utils/logger';
|
||||
import { MCPServerConfig } from '../types';
|
||||
|
||||
/**
|
||||
* MCP Server focused on serving n8n node documentation and code
|
||||
*/
|
||||
export class N8NDocumentationMCPServer {
|
||||
private server: Server;
|
||||
private nodeService: NodeDocumentationService;
|
||||
|
||||
constructor(config: MCPServerConfig) {
|
||||
logger.info('Initializing n8n Documentation MCP server', { config });
|
||||
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'n8n-node-documentation',
|
||||
version: '2.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.nodeService = new NodeDocumentationService();
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: nodeDocumentationTools,
|
||||
}));
|
||||
|
||||
// List available resources
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
{
|
||||
uri: 'nodes://list',
|
||||
name: 'Available n8n Nodes',
|
||||
description: 'List of all available n8n nodes',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'nodes://statistics',
|
||||
name: 'Database Statistics',
|
||||
description: 'Statistics about the node documentation database',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Read resources
|
||||
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const { uri } = request.params;
|
||||
|
||||
try {
|
||||
if (uri === 'nodes://list') {
|
||||
const nodes = await this.nodeService.listNodes();
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(nodes.map(n => ({
|
||||
nodeType: n.nodeType,
|
||||
name: n.name,
|
||||
displayName: n.displayName,
|
||||
category: n.category,
|
||||
description: n.description,
|
||||
hasDocumentation: !!n.documentation,
|
||||
hasExample: !!n.exampleWorkflow,
|
||||
})), null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (uri === 'nodes://statistics') {
|
||||
const stats = this.nodeService.getStatistics();
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(stats, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific node URIs like nodes://info/n8n-nodes-base.if
|
||||
const nodeMatch = uri.match(/^nodes:\/\/info\/(.+)$/);
|
||||
if (nodeMatch) {
|
||||
const nodeType = nodeMatch[1];
|
||||
const nodeInfo = await this.nodeService.getNodeInfo(nodeType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${nodeType}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(nodeInfo, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Unknown resource: ${uri}`);
|
||||
} catch (error) {
|
||||
logger.error('Resource read error:', error);
|
||||
throw error instanceof McpError ? error : new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Failed to read resource: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_nodes':
|
||||
return await this.handleListNodes(args);
|
||||
|
||||
case 'get_node_info':
|
||||
return await this.handleGetNodeInfo(args);
|
||||
|
||||
case 'search_nodes':
|
||||
return await this.handleSearchNodes(args);
|
||||
|
||||
case 'get_node_example':
|
||||
return await this.handleGetNodeExample(args);
|
||||
|
||||
case 'get_node_source_code':
|
||||
return await this.handleGetNodeSourceCode(args);
|
||||
|
||||
case 'get_node_documentation':
|
||||
return await this.handleGetNodeDocumentation(args);
|
||||
|
||||
case 'rebuild_database':
|
||||
return await this.handleRebuildDatabase(args);
|
||||
|
||||
case 'get_database_statistics':
|
||||
return await this.handleGetStatistics();
|
||||
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Tool execution error (${name}):`, error);
|
||||
throw error instanceof McpError ? error : new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleListNodes(args: any): Promise<any> {
|
||||
const nodes = await this.nodeService.listNodes();
|
||||
|
||||
// Apply filters
|
||||
let filtered = nodes;
|
||||
|
||||
if (args.category) {
|
||||
filtered = filtered.filter(n => n.category === args.category);
|
||||
}
|
||||
|
||||
if (args.packageName) {
|
||||
filtered = filtered.filter(n => n.packageName === args.packageName);
|
||||
}
|
||||
|
||||
if (args.isTrigger !== undefined) {
|
||||
filtered = filtered.filter(n => n.isTrigger === args.isTrigger);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(filtered.map(n => ({
|
||||
nodeType: n.nodeType,
|
||||
name: n.name,
|
||||
displayName: n.displayName,
|
||||
category: n.category,
|
||||
description: n.description,
|
||||
packageName: n.packageName,
|
||||
hasDocumentation: !!n.documentation,
|
||||
hasExample: !!n.exampleWorkflow,
|
||||
isTrigger: n.isTrigger,
|
||||
isWebhook: n.isWebhook,
|
||||
})), null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetNodeInfo(args: any): Promise<any> {
|
||||
if (!args.nodeType) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'nodeType is required');
|
||||
}
|
||||
|
||||
const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
nodeType: nodeInfo.nodeType,
|
||||
name: nodeInfo.name,
|
||||
displayName: nodeInfo.displayName,
|
||||
description: nodeInfo.description,
|
||||
category: nodeInfo.category,
|
||||
packageName: nodeInfo.packageName,
|
||||
sourceCode: nodeInfo.sourceCode,
|
||||
credentialCode: nodeInfo.credentialCode,
|
||||
documentation: nodeInfo.documentation,
|
||||
documentationUrl: nodeInfo.documentationUrl,
|
||||
exampleWorkflow: nodeInfo.exampleWorkflow,
|
||||
exampleParameters: nodeInfo.exampleParameters,
|
||||
propertiesSchema: nodeInfo.propertiesSchema,
|
||||
isTrigger: nodeInfo.isTrigger,
|
||||
isWebhook: nodeInfo.isWebhook,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleSearchNodes(args: any): Promise<any> {
|
||||
if (!args.query) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'query is required');
|
||||
}
|
||||
|
||||
const results = await this.nodeService.searchNodes({
|
||||
query: args.query,
|
||||
category: args.category,
|
||||
limit: args.limit || 20,
|
||||
});
|
||||
|
||||
// Filter by documentation if requested
|
||||
let filtered = results;
|
||||
if (args.hasDocumentation) {
|
||||
filtered = filtered.filter(n => !!n.documentation);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(filtered.map(n => ({
|
||||
nodeType: n.nodeType,
|
||||
name: n.name,
|
||||
displayName: n.displayName,
|
||||
category: n.category,
|
||||
description: n.description,
|
||||
hasDocumentation: !!n.documentation,
|
||||
hasExample: !!n.exampleWorkflow,
|
||||
})), null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetNodeExample(args: any): Promise<any> {
|
||||
if (!args.nodeType) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'nodeType is required');
|
||||
}
|
||||
|
||||
const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`);
|
||||
}
|
||||
|
||||
if (!nodeInfo.exampleWorkflow) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No example available for node: ${args.nodeType}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(nodeInfo.exampleWorkflow, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetNodeSourceCode(args: any): Promise<any> {
|
||||
if (!args.nodeType) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'nodeType is required');
|
||||
}
|
||||
|
||||
const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`);
|
||||
}
|
||||
|
||||
const response: any = {
|
||||
nodeType: nodeInfo.nodeType,
|
||||
sourceCode: nodeInfo.sourceCode,
|
||||
};
|
||||
|
||||
if (args.includeCredentials && nodeInfo.credentialCode) {
|
||||
response.credentialCode = nodeInfo.credentialCode;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetNodeDocumentation(args: any): Promise<any> {
|
||||
if (!args.nodeType) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'nodeType is required');
|
||||
}
|
||||
|
||||
const nodeInfo = await this.nodeService.getNodeInfo(args.nodeType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Node not found: ${args.nodeType}`);
|
||||
}
|
||||
|
||||
if (!nodeInfo.documentation) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `No documentation available for node: ${args.nodeType}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const content = args.format === 'plain'
|
||||
? nodeInfo.documentation.replace(/[#*`]/g, '')
|
||||
: nodeInfo.documentation;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleRebuildDatabase(args: any): Promise<any> {
|
||||
logger.info('Starting database rebuild...');
|
||||
|
||||
const stats = await this.nodeService.rebuildDatabase();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
message: 'Database rebuild complete',
|
||||
stats,
|
||||
}, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async handleGetStatistics(): Promise<any> {
|
||||
const stats = this.nodeService.getStatistics();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(stats, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting n8n Documentation MCP server...');
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
|
||||
logger.info('n8n Documentation MCP server started successfully');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Stopping n8n Documentation MCP server...');
|
||||
await this.server.close();
|
||||
this.nodeService.close();
|
||||
logger.info('Server stopped');
|
||||
}
|
||||
}
|
||||
144
src/mcp/tools-v2.ts
Normal file
144
src/mcp/tools-v2.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ToolDefinition } from '../types';
|
||||
|
||||
/**
|
||||
* Simplified MCP tools focused on serving n8n node documentation and code
|
||||
*/
|
||||
export const nodeDocumentationTools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'list_nodes',
|
||||
description: 'List all available n8n nodes with basic information',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by category (e.g., "Core Nodes", "Flow", "Data Transformation")',
|
||||
},
|
||||
packageName: {
|
||||
type: 'string',
|
||||
description: 'Filter by package name (e.g., "n8n-nodes-base")',
|
||||
},
|
||||
isTrigger: {
|
||||
type: 'boolean',
|
||||
description: 'Filter to show only trigger nodes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_info',
|
||||
description: 'Get complete information about a specific n8n node including source code, documentation, and examples',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type or name (e.g., "n8n-nodes-base.if", "If", "webhook")',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: 'Search for n8n nodes by name, description, or documentation content',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query (searches in node names, descriptions, and documentation)',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by category',
|
||||
},
|
||||
hasDocumentation: {
|
||||
type: 'boolean',
|
||||
description: 'Filter to show only nodes with documentation',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return',
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_example',
|
||||
description: 'Get example workflow/usage for a specific n8n node',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type or name',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_source_code',
|
||||
description: 'Get only the source code of a specific n8n node',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type or name',
|
||||
},
|
||||
includeCredentials: {
|
||||
type: 'boolean',
|
||||
description: 'Include credential type definitions if available',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_documentation',
|
||||
description: 'Get only the documentation for a specific n8n node',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type or name',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['markdown', 'plain'],
|
||||
description: 'Documentation format',
|
||||
default: 'markdown',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rebuild_database',
|
||||
description: 'Rebuild the entire node database with latest information from n8n and documentation',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeDocumentation: {
|
||||
type: 'boolean',
|
||||
description: 'Include documentation from n8n-docs repository',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_database_statistics',
|
||||
description: 'Get statistics about the node database',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
67
src/scripts/rebuild-database-v2.ts
Normal file
67
src/scripts/rebuild-database-v2.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { NodeDocumentationService } from '../services/node-documentation-service';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
async function rebuildDatabase() {
|
||||
console.log('🔄 Starting complete database rebuild...\n');
|
||||
|
||||
const service = new NodeDocumentationService();
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('1️⃣ Initializing services...');
|
||||
console.log('2️⃣ Fetching n8n-docs repository...');
|
||||
console.log('3️⃣ Discovering available nodes...');
|
||||
console.log('4️⃣ Extracting node information...\n');
|
||||
|
||||
const stats = await service.rebuildDatabase();
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
console.log('\n📊 Rebuild Results:');
|
||||
console.log(` Total nodes processed: ${stats.total}`);
|
||||
console.log(` Successfully stored: ${stats.successful}`);
|
||||
console.log(` Failed: ${stats.failed}`);
|
||||
console.log(` Duration: ${duration}s`);
|
||||
|
||||
if (stats.errors.length > 0) {
|
||||
console.log('\n⚠️ First 5 errors:');
|
||||
stats.errors.slice(0, 5).forEach(error => {
|
||||
console.log(` - ${error}`);
|
||||
});
|
||||
if (stats.errors.length > 5) {
|
||||
console.log(` ... and ${stats.errors.length - 5} more errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final statistics
|
||||
const dbStats = service.getStatistics();
|
||||
console.log('\n📈 Database Statistics:');
|
||||
console.log(` Total nodes: ${dbStats.totalNodes}`);
|
||||
console.log(` Nodes with documentation: ${dbStats.nodesWithDocs}`);
|
||||
console.log(` Nodes with examples: ${dbStats.nodesWithExamples}`);
|
||||
console.log(` Trigger nodes: ${dbStats.triggerNodes}`);
|
||||
console.log(` Webhook nodes: ${dbStats.webhookNodes}`);
|
||||
console.log(` Total packages: ${dbStats.totalPackages}`);
|
||||
|
||||
console.log('\n✨ Database rebuild complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Database rebuild failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
service.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
rebuildDatabase().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { rebuildDatabase };
|
||||
547
src/services/node-documentation-service.ts
Normal file
547
src/services/node-documentation-service.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { createHash } from 'crypto';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { logger } from '../utils/logger';
|
||||
import { NodeSourceExtractor } from '../utils/node-source-extractor';
|
||||
import { DocumentationFetcher } from '../utils/documentation-fetcher';
|
||||
import { ExampleGenerator } from '../utils/example-generator';
|
||||
|
||||
interface NodeInfo {
|
||||
nodeType: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
icon?: string;
|
||||
sourceCode: string;
|
||||
credentialCode?: string;
|
||||
documentation?: string;
|
||||
documentationUrl?: string;
|
||||
exampleWorkflow?: any;
|
||||
exampleParameters?: any;
|
||||
propertiesSchema?: any;
|
||||
packageName: string;
|
||||
version?: string;
|
||||
codexData?: any;
|
||||
aliases?: string[];
|
||||
hasCredentials: boolean;
|
||||
isTrigger: boolean;
|
||||
isWebhook: boolean;
|
||||
}
|
||||
|
||||
interface SearchOptions {
|
||||
query?: string;
|
||||
nodeType?: string;
|
||||
packageName?: string;
|
||||
category?: string;
|
||||
hasCredentials?: boolean;
|
||||
isTrigger?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class NodeDocumentationService {
|
||||
private db: Database.Database;
|
||||
private extractor: NodeSourceExtractor;
|
||||
private docsFetcher: DocumentationFetcher;
|
||||
|
||||
constructor(dbPath?: string) {
|
||||
const databasePath = dbPath || process.env.NODE_DB_PATH || path.join(process.cwd(), 'data', 'nodes.db');
|
||||
|
||||
// Ensure directory exists
|
||||
const dbDir = path.dirname(databasePath);
|
||||
if (!require('fs').existsSync(dbDir)) {
|
||||
require('fs').mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.db = new Database(databasePath);
|
||||
this.extractor = new NodeSourceExtractor();
|
||||
this.docsFetcher = new DocumentationFetcher();
|
||||
|
||||
// Initialize database with new schema
|
||||
this.initializeDatabase();
|
||||
|
||||
logger.info('Node Documentation Service initialized');
|
||||
}
|
||||
|
||||
private initializeDatabase(): void {
|
||||
// Execute the schema directly
|
||||
const schema = `
|
||||
-- Main nodes table with documentation and examples
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_type TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
subcategory TEXT,
|
||||
icon TEXT,
|
||||
|
||||
-- Source code
|
||||
source_code TEXT NOT NULL,
|
||||
credential_code TEXT,
|
||||
code_hash TEXT NOT NULL,
|
||||
code_length INTEGER NOT NULL,
|
||||
|
||||
-- Documentation
|
||||
documentation_markdown TEXT,
|
||||
documentation_url TEXT,
|
||||
|
||||
-- Example usage
|
||||
example_workflow TEXT,
|
||||
example_parameters TEXT,
|
||||
properties_schema TEXT,
|
||||
|
||||
-- Metadata
|
||||
package_name TEXT NOT NULL,
|
||||
version TEXT,
|
||||
codex_data TEXT,
|
||||
aliases TEXT,
|
||||
|
||||
-- Flags
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
is_trigger INTEGER DEFAULT 0,
|
||||
is_webhook INTEGER DEFAULT 0,
|
||||
|
||||
-- Timestamps
|
||||
extracted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_package_name ON nodes(package_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_category ON nodes(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_code_hash ON nodes(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_is_trigger ON nodes(is_trigger);
|
||||
|
||||
-- Full Text Search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
||||
node_type,
|
||||
name,
|
||||
display_name,
|
||||
description,
|
||||
category,
|
||||
documentation_markdown,
|
||||
aliases,
|
||||
content=nodes,
|
||||
content_rowid=id
|
||||
);
|
||||
|
||||
-- Triggers for FTS
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes
|
||||
BEGIN
|
||||
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
|
||||
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes
|
||||
BEGIN
|
||||
DELETE FROM nodes_fts WHERE rowid = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes
|
||||
BEGIN
|
||||
DELETE FROM nodes_fts WHERE rowid = old.id;
|
||||
INSERT INTO nodes_fts(rowid, node_type, name, display_name, description, category, documentation_markdown, aliases)
|
||||
VALUES (new.id, new.node_type, new.name, new.display_name, new.description, new.category, new.documentation_markdown, new.aliases);
|
||||
END;
|
||||
|
||||
-- Documentation sources table
|
||||
CREATE TABLE IF NOT EXISTS documentation_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
commit_hash TEXT,
|
||||
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Statistics table
|
||||
CREATE TABLE IF NOT EXISTS extraction_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
total_nodes INTEGER NOT NULL,
|
||||
nodes_with_docs INTEGER NOT NULL,
|
||||
nodes_with_examples INTEGER NOT NULL,
|
||||
total_code_size INTEGER NOT NULL,
|
||||
total_docs_size INTEGER NOT NULL,
|
||||
extraction_date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
this.db.exec(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store complete node information including docs and examples
|
||||
*/
|
||||
async storeNode(nodeInfo: NodeInfo): Promise<void> {
|
||||
const hash = this.generateHash(nodeInfo.sourceCode);
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO nodes (
|
||||
node_type, name, display_name, description, category, subcategory, icon,
|
||||
source_code, credential_code, code_hash, code_length,
|
||||
documentation_markdown, documentation_url,
|
||||
example_workflow, example_parameters, properties_schema,
|
||||
package_name, version, codex_data, aliases,
|
||||
has_credentials, is_trigger, is_webhook
|
||||
) VALUES (
|
||||
@nodeType, @name, @displayName, @description, @category, @subcategory, @icon,
|
||||
@sourceCode, @credentialCode, @hash, @codeLength,
|
||||
@documentation, @documentationUrl,
|
||||
@exampleWorkflow, @exampleParameters, @propertiesSchema,
|
||||
@packageName, @version, @codexData, @aliases,
|
||||
@hasCredentials, @isTrigger, @isWebhook
|
||||
)
|
||||
`);
|
||||
|
||||
stmt.run({
|
||||
nodeType: nodeInfo.nodeType,
|
||||
name: nodeInfo.name,
|
||||
displayName: nodeInfo.displayName || nodeInfo.name,
|
||||
description: nodeInfo.description || '',
|
||||
category: nodeInfo.category || 'Other',
|
||||
subcategory: nodeInfo.subcategory || null,
|
||||
icon: nodeInfo.icon || null,
|
||||
sourceCode: nodeInfo.sourceCode,
|
||||
credentialCode: nodeInfo.credentialCode || null,
|
||||
hash,
|
||||
codeLength: nodeInfo.sourceCode.length,
|
||||
documentation: nodeInfo.documentation || null,
|
||||
documentationUrl: nodeInfo.documentationUrl || null,
|
||||
exampleWorkflow: nodeInfo.exampleWorkflow ? JSON.stringify(nodeInfo.exampleWorkflow) : null,
|
||||
exampleParameters: nodeInfo.exampleParameters ? JSON.stringify(nodeInfo.exampleParameters) : null,
|
||||
propertiesSchema: nodeInfo.propertiesSchema ? JSON.stringify(nodeInfo.propertiesSchema) : null,
|
||||
packageName: nodeInfo.packageName,
|
||||
version: nodeInfo.version || null,
|
||||
codexData: nodeInfo.codexData ? JSON.stringify(nodeInfo.codexData) : null,
|
||||
aliases: nodeInfo.aliases ? JSON.stringify(nodeInfo.aliases) : null,
|
||||
hasCredentials: nodeInfo.hasCredentials ? 1 : 0,
|
||||
isTrigger: nodeInfo.isTrigger ? 1 : 0,
|
||||
isWebhook: nodeInfo.isWebhook ? 1 : 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete node information
|
||||
*/
|
||||
async getNodeInfo(nodeType: string): Promise<NodeInfo | null> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM nodes WHERE node_type = ? OR name = ? COLLATE NOCASE
|
||||
`);
|
||||
|
||||
const row = stmt.get(nodeType, nodeType);
|
||||
if (!row) return null;
|
||||
|
||||
return this.rowToNodeInfo(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search nodes with various filters
|
||||
*/
|
||||
async searchNodes(options: SearchOptions): Promise<NodeInfo[]> {
|
||||
let query = 'SELECT * FROM nodes WHERE 1=1';
|
||||
const params: any = {};
|
||||
|
||||
if (options.query) {
|
||||
query += ` AND id IN (
|
||||
SELECT rowid FROM nodes_fts
|
||||
WHERE nodes_fts MATCH @query
|
||||
)`;
|
||||
params.query = options.query;
|
||||
}
|
||||
|
||||
if (options.nodeType) {
|
||||
query += ' AND node_type LIKE @nodeType';
|
||||
params.nodeType = `%${options.nodeType}%`;
|
||||
}
|
||||
|
||||
if (options.packageName) {
|
||||
query += ' AND package_name = @packageName';
|
||||
params.packageName = options.packageName;
|
||||
}
|
||||
|
||||
if (options.category) {
|
||||
query += ' AND category = @category';
|
||||
params.category = options.category;
|
||||
}
|
||||
|
||||
if (options.hasCredentials !== undefined) {
|
||||
query += ' AND has_credentials = @hasCredentials';
|
||||
params.hasCredentials = options.hasCredentials ? 1 : 0;
|
||||
}
|
||||
|
||||
if (options.isTrigger !== undefined) {
|
||||
query += ' AND is_trigger = @isTrigger';
|
||||
params.isTrigger = options.isTrigger ? 1 : 0;
|
||||
}
|
||||
|
||||
query += ' ORDER BY name LIMIT @limit';
|
||||
params.limit = options.limit || 20;
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(params);
|
||||
|
||||
return rows.map(row => this.rowToNodeInfo(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all nodes
|
||||
*/
|
||||
async listNodes(): Promise<NodeInfo[]> {
|
||||
const stmt = this.db.prepare('SELECT * FROM nodes ORDER BY name');
|
||||
const rows = stmt.all();
|
||||
return rows.map(row => this.rowToNodeInfo(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and store all nodes with documentation
|
||||
*/
|
||||
async rebuildDatabase(): Promise<{
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
logger.info('Starting complete database rebuild...');
|
||||
|
||||
// Clear existing data
|
||||
this.db.exec('DELETE FROM nodes');
|
||||
this.db.exec('DELETE FROM extraction_stats');
|
||||
|
||||
// Ensure documentation repository is available
|
||||
await this.docsFetcher.ensureDocsRepository();
|
||||
|
||||
const stats = {
|
||||
total: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all available nodes
|
||||
const availableNodes = await this.extractor.listAvailableNodes();
|
||||
stats.total = availableNodes.length;
|
||||
|
||||
logger.info(`Found ${stats.total} nodes to process`);
|
||||
|
||||
// Process nodes in batches
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < availableNodes.length; i += batchSize) {
|
||||
const batch = availableNodes.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(batch.map(async (node) => {
|
||||
try {
|
||||
// Build node type from package name and node name
|
||||
const nodeType = `n8n-nodes-base.${node.name}`;
|
||||
|
||||
// Extract source code
|
||||
const nodeData = await this.extractor.extractNodeSource(nodeType);
|
||||
if (!nodeData || !nodeData.sourceCode) {
|
||||
throw new Error('Failed to extract node source');
|
||||
}
|
||||
|
||||
// Parse node definition to get metadata
|
||||
const nodeDefinition = this.parseNodeDefinition(nodeData.sourceCode);
|
||||
|
||||
// Get documentation
|
||||
const docs = await this.docsFetcher.getNodeDocumentation(nodeType);
|
||||
|
||||
// Generate example
|
||||
const example = ExampleGenerator.generateFromNodeDefinition(nodeDefinition);
|
||||
|
||||
// Prepare node info
|
||||
const nodeInfo: NodeInfo = {
|
||||
nodeType: nodeType,
|
||||
name: node.name,
|
||||
displayName: nodeDefinition.displayName || node.displayName || node.name,
|
||||
description: nodeDefinition.description || node.description || '',
|
||||
category: nodeDefinition.category || 'Other',
|
||||
subcategory: nodeDefinition.subcategory,
|
||||
icon: nodeDefinition.icon,
|
||||
sourceCode: nodeData.sourceCode,
|
||||
credentialCode: nodeData.credentialCode,
|
||||
documentation: docs?.markdown,
|
||||
documentationUrl: docs?.url,
|
||||
exampleWorkflow: example,
|
||||
exampleParameters: example.nodes[0]?.parameters,
|
||||
propertiesSchema: nodeDefinition.properties,
|
||||
packageName: nodeData.packageInfo?.name || 'n8n-nodes-base',
|
||||
version: nodeDefinition.version,
|
||||
codexData: nodeDefinition.codex,
|
||||
aliases: nodeDefinition.alias,
|
||||
hasCredentials: !!nodeData.credentialCode,
|
||||
isTrigger: node.name.toLowerCase().includes('trigger'),
|
||||
isWebhook: node.name.toLowerCase().includes('webhook')
|
||||
};
|
||||
|
||||
// Store in database
|
||||
await this.storeNode(nodeInfo);
|
||||
|
||||
stats.successful++;
|
||||
logger.debug(`Processed node: ${nodeType}`);
|
||||
} catch (error) {
|
||||
stats.failed++;
|
||||
const errorMsg = `Failed to process ${node.name}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
stats.errors.push(errorMsg);
|
||||
logger.error(errorMsg);
|
||||
}
|
||||
}));
|
||||
|
||||
logger.info(`Progress: ${Math.min(i + batchSize, availableNodes.length)}/${stats.total} nodes processed`);
|
||||
}
|
||||
|
||||
// Store statistics
|
||||
this.storeStatistics(stats);
|
||||
|
||||
logger.info(`Database rebuild complete: ${stats.successful} successful, ${stats.failed} failed`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Database rebuild failed:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse node definition from source code
|
||||
*/
|
||||
private parseNodeDefinition(sourceCode: string): any {
|
||||
try {
|
||||
// Try to extract the description object from the source
|
||||
const descMatch = sourceCode.match(/description\s*[:=]\s*({[\s\S]*?})\s*[,;]/);
|
||||
if (descMatch) {
|
||||
// Clean up the match and try to parse it
|
||||
const descStr = descMatch[1]
|
||||
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?\s*:/g, '"$2":') // Quote property names
|
||||
.replace(/:\s*'([^']*)'/g, ': "$1"') // Convert single quotes to double
|
||||
.replace(/,\s*}/g, '}'); // Remove trailing commas
|
||||
|
||||
return JSON.parse(descStr);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to parse node definition:', error);
|
||||
}
|
||||
|
||||
// Return minimal definition if parsing fails
|
||||
return {
|
||||
displayName: '',
|
||||
description: '',
|
||||
properties: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert database row to NodeInfo
|
||||
*/
|
||||
private rowToNodeInfo(row: any): NodeInfo {
|
||||
return {
|
||||
nodeType: row.node_type,
|
||||
name: row.name,
|
||||
displayName: row.display_name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
subcategory: row.subcategory,
|
||||
icon: row.icon,
|
||||
sourceCode: row.source_code,
|
||||
credentialCode: row.credential_code,
|
||||
documentation: row.documentation_markdown,
|
||||
documentationUrl: row.documentation_url,
|
||||
exampleWorkflow: row.example_workflow ? JSON.parse(row.example_workflow) : null,
|
||||
exampleParameters: row.example_parameters ? JSON.parse(row.example_parameters) : null,
|
||||
propertiesSchema: row.properties_schema ? JSON.parse(row.properties_schema) : null,
|
||||
packageName: row.package_name,
|
||||
version: row.version,
|
||||
codexData: row.codex_data ? JSON.parse(row.codex_data) : null,
|
||||
aliases: row.aliases ? JSON.parse(row.aliases) : null,
|
||||
hasCredentials: row.has_credentials === 1,
|
||||
isTrigger: row.is_trigger === 1,
|
||||
isWebhook: row.is_webhook === 1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash for content
|
||||
*/
|
||||
private generateHash(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store extraction statistics
|
||||
*/
|
||||
private storeStatistics(stats: any): void {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO extraction_stats (
|
||||
total_nodes, nodes_with_docs, nodes_with_examples,
|
||||
total_code_size, total_docs_size
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// Calculate sizes
|
||||
const sizeStats = this.db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN documentation_markdown IS NOT NULL THEN 1 ELSE 0 END) as with_docs,
|
||||
SUM(CASE WHEN example_workflow IS NOT NULL THEN 1 ELSE 0 END) as with_examples,
|
||||
SUM(code_length) as code_size,
|
||||
SUM(LENGTH(documentation_markdown)) as docs_size
|
||||
FROM nodes
|
||||
`).get() as any;
|
||||
|
||||
stmt.run(
|
||||
stats.successful,
|
||||
sizeStats?.with_docs || 0,
|
||||
sizeStats?.with_examples || 0,
|
||||
sizeStats?.code_size || 0,
|
||||
sizeStats?.docs_size || 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
getStatistics(): any {
|
||||
const stats = this.db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as totalNodes,
|
||||
COUNT(DISTINCT package_name) as totalPackages,
|
||||
SUM(code_length) as totalCodeSize,
|
||||
SUM(CASE WHEN documentation_markdown IS NOT NULL THEN 1 ELSE 0 END) as nodesWithDocs,
|
||||
SUM(CASE WHEN example_workflow IS NOT NULL THEN 1 ELSE 0 END) as nodesWithExamples,
|
||||
SUM(has_credentials) as nodesWithCredentials,
|
||||
SUM(is_trigger) as triggerNodes,
|
||||
SUM(is_webhook) as webhookNodes
|
||||
FROM nodes
|
||||
`).get() as any;
|
||||
|
||||
const packages = this.db.prepare(`
|
||||
SELECT package_name as package, COUNT(*) as count
|
||||
FROM nodes
|
||||
GROUP BY package_name
|
||||
ORDER BY count DESC
|
||||
`).all();
|
||||
|
||||
return {
|
||||
totalNodes: stats?.totalNodes || 0,
|
||||
totalPackages: stats?.totalPackages || 0,
|
||||
totalCodeSize: stats?.totalCodeSize || 0,
|
||||
nodesWithDocs: stats?.nodesWithDocs || 0,
|
||||
nodesWithExamples: stats?.nodesWithExamples || 0,
|
||||
nodesWithCredentials: stats?.nodesWithCredentials || 0,
|
||||
triggerNodes: stats?.triggerNodes || 0,
|
||||
webhookNodes: stats?.webhookNodes || 0,
|
||||
packageDistribution: packages
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
241
src/utils/documentation-fetcher.ts
Normal file
241
src/utils/documentation-fetcher.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
interface NodeDocumentation {
|
||||
markdown: string;
|
||||
url: string;
|
||||
examples?: any[];
|
||||
}
|
||||
|
||||
export class DocumentationFetcher {
|
||||
private docsPath: string;
|
||||
private docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||
private cloned = false;
|
||||
|
||||
constructor(docsPath?: string) {
|
||||
this.docsPath = docsPath || path.join(process.cwd(), 'temp', 'n8n-docs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone or update the n8n-docs repository
|
||||
*/
|
||||
async ensureDocsRepository(): Promise<void> {
|
||||
try {
|
||||
// Check if directory exists
|
||||
const exists = await fs.access(this.docsPath).then(() => true).catch(() => false);
|
||||
|
||||
if (!exists) {
|
||||
logger.info('Cloning n8n-docs repository...');
|
||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||
execSync(`git clone --depth 1 ${this.docsRepoUrl} ${this.docsPath}`, {
|
||||
stdio: 'pipe'
|
||||
});
|
||||
logger.info('n8n-docs repository cloned successfully');
|
||||
} else {
|
||||
logger.info('Updating n8n-docs repository...');
|
||||
execSync('git pull --ff-only', {
|
||||
cwd: this.docsPath,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
logger.info('n8n-docs repository updated');
|
||||
}
|
||||
|
||||
this.cloned = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to clone/update n8n-docs repository:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get documentation for a specific node
|
||||
*/
|
||||
async getNodeDocumentation(nodeType: string): Promise<NodeDocumentation | null> {
|
||||
if (!this.cloned) {
|
||||
await this.ensureDocsRepository();
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert node type to documentation path
|
||||
// e.g., "n8n-nodes-base.if" -> "if"
|
||||
const nodeName = this.extractNodeName(nodeType);
|
||||
|
||||
// Common documentation paths to check
|
||||
const possiblePaths = [
|
||||
path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'core-nodes', `${nodeName}.md`),
|
||||
path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'app-nodes', `${nodeName}.md`),
|
||||
path.join(this.docsPath, 'docs', 'integrations', 'builtin', 'trigger-nodes', `${nodeName}.md`),
|
||||
path.join(this.docsPath, 'docs', 'code-examples', 'expressions', `${nodeName}.md`),
|
||||
// Generic search in docs folder
|
||||
path.join(this.docsPath, 'docs', '**', `${nodeName}.md`)
|
||||
];
|
||||
|
||||
for (const docPath of possiblePaths) {
|
||||
try {
|
||||
const content = await fs.readFile(docPath, 'utf-8');
|
||||
const url = this.generateDocUrl(docPath);
|
||||
|
||||
return {
|
||||
markdown: content,
|
||||
url,
|
||||
examples: this.extractExamples(content)
|
||||
};
|
||||
} catch (error) {
|
||||
// Continue to next path
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, try to find by searching
|
||||
const foundPath = await this.searchForNodeDoc(nodeName);
|
||||
if (foundPath) {
|
||||
const content = await fs.readFile(foundPath, 'utf-8');
|
||||
return {
|
||||
markdown: content,
|
||||
url: this.generateDocUrl(foundPath),
|
||||
examples: this.extractExamples(content)
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`No documentation found for node: ${nodeType}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get documentation for ${nodeType}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node name from node type
|
||||
*/
|
||||
private extractNodeName(nodeType: string): string {
|
||||
// Handle different node type formats
|
||||
// "n8n-nodes-base.if" -> "if"
|
||||
// "@n8n/n8n-nodes-langchain.Agent" -> "agent"
|
||||
const parts = nodeType.split('.');
|
||||
const name = parts[parts.length - 1];
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node documentation file
|
||||
*/
|
||||
private async searchForNodeDoc(nodeName: string): Promise<string | null> {
|
||||
try {
|
||||
const result = execSync(
|
||||
`find ${this.docsPath}/docs -name "*.md" -type f | grep -i "${nodeName}" | head -1`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe' }
|
||||
).trim();
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate documentation URL from file path
|
||||
*/
|
||||
private generateDocUrl(filePath: string): string {
|
||||
const relativePath = path.relative(this.docsPath, filePath);
|
||||
const urlPath = relativePath
|
||||
.replace(/^docs\//, '')
|
||||
.replace(/\.md$/, '')
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
return `https://docs.n8n.io/${urlPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code examples from markdown content
|
||||
*/
|
||||
private extractExamples(markdown: string): any[] {
|
||||
const examples: any[] = [];
|
||||
|
||||
// Extract JSON code blocks
|
||||
const jsonCodeBlockRegex = /```json\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
|
||||
while ((match = jsonCodeBlockRegex.exec(markdown)) !== null) {
|
||||
try {
|
||||
const json = JSON.parse(match[1]);
|
||||
examples.push(json);
|
||||
} catch (error) {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Extract workflow examples
|
||||
const workflowExampleRegex = /## Example.*?\n([\s\S]*?)(?=\n##|\n#|$)/gi;
|
||||
while ((match = workflowExampleRegex.exec(markdown)) !== null) {
|
||||
const exampleText = match[1];
|
||||
// Try to find JSON in the example section
|
||||
const jsonMatch = exampleText.match(/```json\n([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const json = JSON.parse(jsonMatch[1]);
|
||||
examples.push(json);
|
||||
} catch (error) {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available documentation files
|
||||
*/
|
||||
async getAllDocumentationFiles(): Promise<Map<string, string>> {
|
||||
if (!this.cloned) {
|
||||
await this.ensureDocsRepository();
|
||||
}
|
||||
|
||||
const docMap = new Map<string, string>();
|
||||
|
||||
try {
|
||||
const findDocs = execSync(
|
||||
`find ${this.docsPath}/docs -name "*.md" -type f | grep -E "(core-nodes|app-nodes|trigger-nodes)/"`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe' }
|
||||
).trim().split('\n');
|
||||
|
||||
for (const docPath of findDocs) {
|
||||
if (!docPath) continue;
|
||||
|
||||
const filename = path.basename(docPath, '.md');
|
||||
const content = await fs.readFile(docPath, 'utf-8');
|
||||
|
||||
// Try to extract the node type from the content
|
||||
const nodeTypeMatch = content.match(/node[_-]?type[:\s]+["']?([^"'\s]+)["']?/i);
|
||||
if (nodeTypeMatch) {
|
||||
docMap.set(nodeTypeMatch[1], docPath);
|
||||
} else {
|
||||
// Use filename as fallback
|
||||
docMap.set(filename, docPath);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${docMap.size} documentation files`);
|
||||
return docMap;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get documentation files:', error);
|
||||
return docMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up cloned repository
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
try {
|
||||
await fs.rm(this.docsPath, { recursive: true, force: true });
|
||||
this.cloned = false;
|
||||
logger.info('Cleaned up documentation repository');
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup docs repository:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/utils/example-generator.ts
Normal file
267
src/utils/example-generator.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { logger } from './logger';
|
||||
|
||||
interface NodeExample {
|
||||
nodes: any[];
|
||||
connections: any;
|
||||
pinData?: any;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
interface NodeParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
default?: any;
|
||||
options?: any[];
|
||||
displayOptions?: any;
|
||||
}
|
||||
|
||||
export class ExampleGenerator {
|
||||
/**
|
||||
* Generate example workflow for a node
|
||||
*/
|
||||
static generateNodeExample(nodeType: string, nodeData: any): NodeExample {
|
||||
const nodeName = this.getNodeName(nodeType);
|
||||
const nodeId = this.generateNodeId();
|
||||
|
||||
// Base example structure
|
||||
const example: NodeExample = {
|
||||
nodes: [{
|
||||
parameters: this.generateExampleParameters(nodeType, nodeData),
|
||||
type: nodeType,
|
||||
typeVersion: nodeData.typeVersion || 1,
|
||||
position: [220, 120],
|
||||
id: nodeId,
|
||||
name: nodeName
|
||||
}],
|
||||
connections: {
|
||||
[nodeName]: {
|
||||
main: [[]]
|
||||
}
|
||||
},
|
||||
pinData: {},
|
||||
meta: {
|
||||
templateCredsSetupCompleted: true,
|
||||
instanceId: this.generateInstanceId()
|
||||
}
|
||||
};
|
||||
|
||||
// Add specific configurations based on node type
|
||||
this.addNodeSpecificConfig(nodeType, example, nodeData);
|
||||
|
||||
return example;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate example parameters based on node type
|
||||
*/
|
||||
private static generateExampleParameters(nodeType: string, nodeData: any): any {
|
||||
const params: any = {};
|
||||
|
||||
// Extract node name for specific handling
|
||||
const nodeName = nodeType.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Common node examples
|
||||
switch (nodeName) {
|
||||
case 'if':
|
||||
return {
|
||||
conditions: {
|
||||
options: {
|
||||
caseSensitive: true,
|
||||
leftValue: "",
|
||||
typeValidation: "strict",
|
||||
version: 2
|
||||
},
|
||||
conditions: [{
|
||||
id: this.generateNodeId(),
|
||||
leftValue: "={{ $json }}",
|
||||
rightValue: "",
|
||||
operator: {
|
||||
type: "object",
|
||||
operation: "notEmpty",
|
||||
singleValue: true
|
||||
}
|
||||
}],
|
||||
combinator: "and"
|
||||
},
|
||||
options: {}
|
||||
};
|
||||
|
||||
case 'webhook':
|
||||
return {
|
||||
httpMethod: "POST",
|
||||
path: "webhook-path",
|
||||
responseMode: "onReceived",
|
||||
responseData: "allEntries",
|
||||
options: {}
|
||||
};
|
||||
|
||||
case 'httprequest':
|
||||
return {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/data",
|
||||
authentication: "none",
|
||||
options: {},
|
||||
headerParametersUi: {
|
||||
parameter: []
|
||||
}
|
||||
};
|
||||
|
||||
case 'function':
|
||||
return {
|
||||
functionCode: "// Add your JavaScript code here\nreturn $input.all();"
|
||||
};
|
||||
|
||||
case 'set':
|
||||
return {
|
||||
mode: "manual",
|
||||
duplicateItem: false,
|
||||
values: {
|
||||
string: [{
|
||||
name: "myField",
|
||||
value: "myValue"
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
case 'split':
|
||||
return {
|
||||
batchSize: 10,
|
||||
options: {}
|
||||
};
|
||||
|
||||
default:
|
||||
// Generate generic parameters from node properties
|
||||
return this.generateGenericParameters(nodeData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate generic parameters from node properties
|
||||
*/
|
||||
private static generateGenericParameters(nodeData: any): any {
|
||||
const params: any = {};
|
||||
|
||||
if (nodeData.properties) {
|
||||
for (const prop of nodeData.properties) {
|
||||
if (prop.default !== undefined) {
|
||||
params[prop.name] = prop.default;
|
||||
} else if (prop.type === 'string') {
|
||||
params[prop.name] = '';
|
||||
} else if (prop.type === 'number') {
|
||||
params[prop.name] = 0;
|
||||
} else if (prop.type === 'boolean') {
|
||||
params[prop.name] = false;
|
||||
} else if (prop.type === 'options' && prop.options?.length > 0) {
|
||||
params[prop.name] = prop.options[0].value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add node-specific configurations
|
||||
*/
|
||||
private static addNodeSpecificConfig(nodeType: string, example: NodeExample, nodeData: any): void {
|
||||
const nodeName = nodeType.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Add specific connection structures for different node types
|
||||
switch (nodeName) {
|
||||
case 'if':
|
||||
// IF node has true/false outputs
|
||||
example.connections[example.nodes[0].name] = {
|
||||
main: [[], []] // Two outputs: true, false
|
||||
};
|
||||
break;
|
||||
|
||||
case 'switch':
|
||||
// Switch node can have multiple outputs
|
||||
const outputs = nodeData.outputs || 3;
|
||||
example.connections[example.nodes[0].name] = {
|
||||
main: Array(outputs).fill([])
|
||||
};
|
||||
break;
|
||||
|
||||
case 'merge':
|
||||
// Merge node has multiple inputs
|
||||
example.nodes[0].position = [400, 120];
|
||||
// Add dummy input nodes
|
||||
example.nodes.push({
|
||||
parameters: {},
|
||||
type: "n8n-nodes-base.noOp",
|
||||
typeVersion: 1,
|
||||
position: [200, 60],
|
||||
id: this.generateNodeId(),
|
||||
name: "Input 1"
|
||||
});
|
||||
example.nodes.push({
|
||||
parameters: {},
|
||||
type: "n8n-nodes-base.noOp",
|
||||
typeVersion: 1,
|
||||
position: [200, 180],
|
||||
id: this.generateNodeId(),
|
||||
name: "Input 2"
|
||||
});
|
||||
example.connections = {
|
||||
"Input 1": { main: [[{ node: example.nodes[0].name, type: "main", index: 0 }]] },
|
||||
"Input 2": { main: [[{ node: example.nodes[0].name, type: "main", index: 1 }]] },
|
||||
[example.nodes[0].name]: { main: [[]] }
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Add credentials if needed
|
||||
if (nodeData.credentials?.length > 0) {
|
||||
example.nodes[0].credentials = {};
|
||||
for (const cred of nodeData.credentials) {
|
||||
example.nodes[0].credentials[cred.name] = {
|
||||
id: this.generateNodeId(),
|
||||
name: `${cred.name} account`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display name from node type
|
||||
*/
|
||||
private static getNodeName(nodeType: string): string {
|
||||
const parts = nodeType.split('.');
|
||||
const name = parts[parts.length - 1];
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random node ID
|
||||
*/
|
||||
private static generateNodeId(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate instance ID
|
||||
*/
|
||||
private static generateInstanceId(): string {
|
||||
return Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate example from node definition
|
||||
*/
|
||||
static generateFromNodeDefinition(nodeDefinition: any): NodeExample {
|
||||
const nodeType = nodeDefinition.description?.name || 'n8n-nodes-base.node';
|
||||
const nodeData = {
|
||||
typeVersion: nodeDefinition.description?.version || 1,
|
||||
properties: nodeDefinition.description?.properties || [],
|
||||
credentials: nodeDefinition.description?.credentials || [],
|
||||
outputs: nodeDefinition.description?.outputs || ['main']
|
||||
};
|
||||
|
||||
return this.generateNodeExample(nodeType, nodeData);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user