Add AI Agent node source code extraction capability
This commit implements the ability to extract n8n node source code through MCP:
Features:
- New MCP tools: get_node_source_code and list_available_nodes
- NodeSourceExtractor utility for file system access to n8n nodes
- Support for extracting any n8n node including AI Agent from @n8n/n8n-nodes-langchain
- Resource endpoint for accessing node source: nodes://source/{nodeType}
Testing:
- Docker test environment with mounted n8n node_modules
- Multiple test scripts for different scenarios
- Comprehensive test documentation
- Standalone MCP client test demonstrating full extraction flow
The implementation successfully demonstrates:
1. MCP server can access n8n's installed nodes
2. Source code can be extracted and returned to MCP clients
3. Full metadata including package info and file locations
4. Support for credential code extraction when available
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -138,4 +138,31 @@ export class N8NApiClient {
|
||||
async getNodeType(nodeType: string): Promise<any> {
|
||||
return this.request(`/node-types/${nodeType}`);
|
||||
}
|
||||
|
||||
// Extended methods for node source extraction
|
||||
async getNodeSourceCode(nodeType: string): Promise<any> {
|
||||
// This is a special endpoint we'll need to handle differently
|
||||
// as n8n doesn't expose source code directly through API
|
||||
// We'll need to implement this through file system access
|
||||
throw new Error('Node source code extraction requires special implementation');
|
||||
}
|
||||
|
||||
async getNodeDescription(nodeType: string): Promise<any> {
|
||||
try {
|
||||
const nodeTypeData = await this.getNodeType(nodeType);
|
||||
return {
|
||||
name: nodeTypeData.name,
|
||||
displayName: nodeTypeData.displayName,
|
||||
description: nodeTypeData.description,
|
||||
version: nodeTypeData.version,
|
||||
defaults: nodeTypeData.defaults,
|
||||
inputs: nodeTypeData.inputs,
|
||||
outputs: nodeTypeData.outputs,
|
||||
properties: nodeTypeData.properties,
|
||||
credentials: nodeTypeData.credentials,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get node description: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/utils/node-source-extractor.ts
Normal file
203
src/utils/node-source-extractor.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface NodeSourceInfo {
|
||||
nodeType: string;
|
||||
sourceCode: string;
|
||||
credentialCode?: string;
|
||||
packageInfo?: any;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export class NodeSourceExtractor {
|
||||
private n8nBasePaths = [
|
||||
'/usr/local/lib/node_modules/n8n/node_modules',
|
||||
'/app/node_modules',
|
||||
'/home/node/.n8n/custom/nodes',
|
||||
'./node_modules',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract source code for a specific n8n node
|
||||
*/
|
||||
async extractNodeSource(nodeType: string): Promise<NodeSourceInfo> {
|
||||
logger.info(`Extracting source code for node: ${nodeType}`);
|
||||
|
||||
// Parse node type to get package and node name
|
||||
const { packageName, nodeName } = this.parseNodeType(nodeType);
|
||||
|
||||
// Search for the node in known locations
|
||||
for (const basePath of this.n8nBasePaths) {
|
||||
try {
|
||||
const nodeInfo = await this.searchNodeInPath(basePath, packageName, nodeName);
|
||||
if (nodeInfo) {
|
||||
logger.info(`Found node source at: ${nodeInfo.location}`);
|
||||
return nodeInfo;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to search in ${basePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Node source code not found for: ${nodeType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse node type identifier
|
||||
*/
|
||||
private parseNodeType(nodeType: string): { packageName: string; nodeName: string } {
|
||||
// Handle different formats:
|
||||
// - @n8n/n8n-nodes-langchain.Agent
|
||||
// - n8n-nodes-base.HttpRequest
|
||||
// - customNode
|
||||
|
||||
if (nodeType.includes('.')) {
|
||||
const [pkg, node] = nodeType.split('.');
|
||||
return { packageName: pkg, nodeName: node };
|
||||
}
|
||||
|
||||
// Default to n8n-nodes-base for simple node names
|
||||
return { packageName: 'n8n-nodes-base', nodeName: nodeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for node in a specific path
|
||||
*/
|
||||
private async searchNodeInPath(
|
||||
basePath: string,
|
||||
packageName: string,
|
||||
nodeName: string
|
||||
): Promise<NodeSourceInfo | null> {
|
||||
try {
|
||||
// Common patterns for node files
|
||||
const patterns = [
|
||||
`${packageName}/dist/nodes/${nodeName}/${nodeName}.node.js`,
|
||||
`${packageName}/dist/nodes/${nodeName}.node.js`,
|
||||
`${packageName}/nodes/${nodeName}/${nodeName}.node.js`,
|
||||
`${packageName}/nodes/${nodeName}.node.js`,
|
||||
`${nodeName}/${nodeName}.node.js`,
|
||||
`${nodeName}.node.js`,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const fullPath = path.join(basePath, pattern);
|
||||
try {
|
||||
const sourceCode = await fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Try to find credential file
|
||||
const credentialPath = fullPath.replace('.node.js', '.credentials.js');
|
||||
let credentialCode: string | undefined;
|
||||
try {
|
||||
credentialCode = await fs.readFile(credentialPath, 'utf-8');
|
||||
} catch {
|
||||
// Credential file is optional
|
||||
}
|
||||
|
||||
// Try to get package.json info
|
||||
const packageJsonPath = path.join(basePath, packageName, 'package.json');
|
||||
let packageInfo: any;
|
||||
try {
|
||||
const packageJson = await fs.readFile(packageJsonPath, 'utf-8');
|
||||
packageInfo = JSON.parse(packageJson);
|
||||
} catch {
|
||||
// Package.json is optional
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType: `${packageName}.${nodeName}`,
|
||||
sourceCode,
|
||||
credentialCode,
|
||||
packageInfo,
|
||||
location: fullPath,
|
||||
};
|
||||
} catch {
|
||||
// Continue searching
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error searching in path ${basePath}: ${error}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available nodes
|
||||
*/
|
||||
async listAvailableNodes(category?: string, search?: string): Promise<any[]> {
|
||||
const nodes: any[] = [];
|
||||
|
||||
for (const basePath of this.n8nBasePaths) {
|
||||
try {
|
||||
await this.scanDirectoryForNodes(basePath, nodes, category, search);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to scan ${basePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan directory for n8n nodes
|
||||
*/
|
||||
private async scanDirectoryForNodes(
|
||||
dirPath: string,
|
||||
nodes: any[],
|
||||
category?: string,
|
||||
search?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.node.js')) {
|
||||
try {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Extract basic info from the source
|
||||
const nameMatch = content.match(/displayName:\s*['"`]([^'"`]+)['"`]/);
|
||||
const descriptionMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/);
|
||||
|
||||
if (nameMatch) {
|
||||
const nodeInfo = {
|
||||
name: entry.name.replace('.node.js', ''),
|
||||
displayName: nameMatch[1],
|
||||
description: descriptionMatch ? descriptionMatch[1] : '',
|
||||
location: fullPath,
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
if (category && !nodeInfo.displayName.toLowerCase().includes(category.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (search && !nodeInfo.displayName.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!nodeInfo.description.toLowerCase().includes(search.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push(nodeInfo);
|
||||
}
|
||||
} catch {
|
||||
// Skip files we can't read
|
||||
}
|
||||
} else if (entry.isDirectory() && entry.name !== 'node_modules') {
|
||||
// Recursively scan subdirectories
|
||||
await this.scanDirectoryForNodes(path.join(dirPath, entry.name), nodes, category, search);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error scanning directory ${dirPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract AI Agent node specifically
|
||||
*/
|
||||
async extractAIAgentNode(): Promise<NodeSourceInfo> {
|
||||
// AI Agent is typically in @n8n/n8n-nodes-langchain package
|
||||
return this.extractNodeSource('@n8n/n8n-nodes-langchain.Agent');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user