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:
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