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:
@@ -31,4 +31,10 @@ export const n8nResources: ResourceDefinition[] = [
|
||||
description: 'List of all available n8n nodes',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'nodes://source',
|
||||
name: 'Node Source Code',
|
||||
description: 'Source code of n8n nodes',
|
||||
mimeType: 'text/javascript',
|
||||
},
|
||||
];
|
||||
@@ -15,13 +15,16 @@ import { n8nPrompts } from './prompts';
|
||||
import { N8NApiClient } from '../utils/n8n-client';
|
||||
import { N8NMCPBridge } from '../utils/bridge';
|
||||
import { logger } from '../utils/logger';
|
||||
import { NodeSourceExtractor } from '../utils/node-source-extractor';
|
||||
|
||||
export class N8NMCPServer {
|
||||
private server: Server;
|
||||
private n8nClient: N8NApiClient;
|
||||
private nodeExtractor: NodeSourceExtractor;
|
||||
|
||||
constructor(config: MCPServerConfig, n8nConfig: N8NConfig) {
|
||||
this.n8nClient = new N8NApiClient(n8nConfig);
|
||||
this.nodeExtractor = new NodeSourceExtractor();
|
||||
logger.info('Initializing n8n MCP server', { config, n8nConfig });
|
||||
this.server = new Server(
|
||||
{
|
||||
@@ -154,16 +157,25 @@ export class N8NMCPServer {
|
||||
return this.getExecutions(args);
|
||||
case 'get_execution_data':
|
||||
return this.getExecutionData(args);
|
||||
case 'get_node_source_code':
|
||||
return this.getNodeSourceCode(args);
|
||||
case 'list_available_nodes':
|
||||
return this.listAvailableNodes(args);
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async readResource(uri: string): Promise<any> {
|
||||
// Resource reading logic will be implemented
|
||||
// Resource reading logic
|
||||
if (uri.startsWith('workflow://')) {
|
||||
const workflowId = uri.replace('workflow://', '');
|
||||
return this.getWorkflow({ id: workflowId });
|
||||
} else if (uri === 'nodes://available') {
|
||||
return this.listAvailableNodes({});
|
||||
} else if (uri.startsWith('nodes://source/')) {
|
||||
const nodeType = uri.replace('nodes://source/', '');
|
||||
return this.getNodeSourceCode({ nodeType });
|
||||
}
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
@@ -258,6 +270,50 @@ export class N8NMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async getNodeSourceCode(args: any): Promise<any> {
|
||||
try {
|
||||
logger.info(`Getting source code for node: ${args.nodeType}`);
|
||||
const nodeInfo = await this.nodeExtractor.extractNodeSource(args.nodeType);
|
||||
|
||||
const result: any = {
|
||||
nodeType: nodeInfo.nodeType,
|
||||
sourceCode: nodeInfo.sourceCode,
|
||||
location: nodeInfo.location,
|
||||
};
|
||||
|
||||
if (args.includeCredentials && nodeInfo.credentialCode) {
|
||||
result.credentialCode = nodeInfo.credentialCode;
|
||||
}
|
||||
|
||||
if (nodeInfo.packageInfo) {
|
||||
result.packageInfo = {
|
||||
name: nodeInfo.packageInfo.name,
|
||||
version: nodeInfo.packageInfo.version,
|
||||
description: nodeInfo.packageInfo.description,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get node source code`, error);
|
||||
throw new Error(`Failed to get node source code: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async listAvailableNodes(args: any): Promise<any> {
|
||||
try {
|
||||
logger.info('Listing available nodes', args);
|
||||
const nodes = await this.nodeExtractor.listAvailableNodes(args.category, args.search);
|
||||
return {
|
||||
nodes,
|
||||
total: nodes.length,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list available nodes`, error);
|
||||
throw new Error(`Failed to list available nodes: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
logger.info('Starting n8n MCP server...');
|
||||
|
||||
@@ -145,4 +145,40 @@ export const n8nTools: ToolDefinition[] = [
|
||||
required: ['executionId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_source_code',
|
||||
description: 'Extract source code of a specific n8n node',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type identifier (e.g., @n8n/n8n-nodes-langchain.Agent)',
|
||||
},
|
||||
includeCredentials: {
|
||||
type: 'boolean',
|
||||
description: 'Include credential type definitions if available',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_available_nodes',
|
||||
description: 'List all available n8n nodes with their types',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Filter by category (e.g., AI, Data Transformation)',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
description: 'Search term to filter nodes',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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