356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { n8nDocumentationTools } from './tools-update';
|
|
import { logger } from '../utils/logger';
|
|
import { NodeRepository } from '../database/node-repository';
|
|
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
|
|
|
interface NodeRow {
|
|
node_type: string;
|
|
package_name: string;
|
|
display_name: string;
|
|
description?: string;
|
|
category?: string;
|
|
development_style?: string;
|
|
is_ai_tool: number;
|
|
is_trigger: number;
|
|
is_webhook: number;
|
|
is_versioned: number;
|
|
version?: string;
|
|
documentation?: string;
|
|
properties_schema?: string;
|
|
operations?: string;
|
|
credentials_required?: string;
|
|
}
|
|
|
|
export class N8NDocumentationMCPServer {
|
|
private server: Server;
|
|
private db: DatabaseAdapter | null = null;
|
|
private repository: NodeRepository | null = null;
|
|
private initialized: Promise<void>;
|
|
|
|
constructor() {
|
|
// Try multiple database paths
|
|
const possiblePaths = [
|
|
path.join(process.cwd(), 'data', 'nodes.db'),
|
|
path.join(__dirname, '../../data', 'nodes.db'),
|
|
'./data/nodes.db'
|
|
];
|
|
|
|
let dbPath: string | null = null;
|
|
for (const p of possiblePaths) {
|
|
if (existsSync(p)) {
|
|
dbPath = p;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!dbPath) {
|
|
logger.error('Database not found in any of the expected locations:', possiblePaths);
|
|
throw new Error('Database nodes.db not found. Please run npm run rebuild first.');
|
|
}
|
|
|
|
// Initialize database asynchronously
|
|
this.initialized = this.initializeDatabase(dbPath);
|
|
|
|
logger.info('Initializing n8n Documentation MCP server');
|
|
|
|
this.server = new Server(
|
|
{
|
|
name: 'n8n-documentation-mcp',
|
|
version: '1.0.0',
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
}
|
|
);
|
|
|
|
this.setupHandlers();
|
|
}
|
|
|
|
private async initializeDatabase(dbPath: string): Promise<void> {
|
|
try {
|
|
this.db = await createDatabaseAdapter(dbPath);
|
|
this.repository = new NodeRepository(this.db);
|
|
logger.info(`Initialized database from: ${dbPath}`);
|
|
} catch (error) {
|
|
logger.error('Failed to initialize database:', error);
|
|
throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
private async ensureInitialized(): Promise<void> {
|
|
await this.initialized;
|
|
if (!this.db || !this.repository) {
|
|
throw new Error('Database not initialized');
|
|
}
|
|
}
|
|
|
|
private setupHandlers(): void {
|
|
// Handle tool listing
|
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: n8nDocumentationTools,
|
|
}));
|
|
|
|
// Handle tool execution
|
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const { name, arguments: args } = request.params;
|
|
|
|
try {
|
|
logger.debug(`Executing tool: ${name}`, { args });
|
|
const result = await this.executeTool(name, args);
|
|
logger.debug(`Tool ${name} executed successfully`);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(result, null, 2),
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error executing tool ${name}`, error);
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Error executing tool ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
private async executeTool(name: string, args: any): Promise<any> {
|
|
switch (name) {
|
|
case 'list_nodes':
|
|
return this.listNodes(args);
|
|
case 'get_node_info':
|
|
return this.getNodeInfo(args.nodeType);
|
|
case 'search_nodes':
|
|
return this.searchNodes(args.query, args.limit);
|
|
case 'list_ai_tools':
|
|
return this.listAITools();
|
|
case 'get_node_documentation':
|
|
return this.getNodeDocumentation(args.nodeType);
|
|
case 'get_database_statistics':
|
|
return this.getDatabaseStatistics();
|
|
default:
|
|
throw new Error(`Unknown tool: ${name}`);
|
|
}
|
|
}
|
|
|
|
private listNodes(filters: any = {}): any {
|
|
let query = 'SELECT * FROM nodes WHERE 1=1';
|
|
const params: any[] = [];
|
|
|
|
if (filters.package) {
|
|
query += ' AND package_name = ?';
|
|
params.push(filters.package);
|
|
}
|
|
|
|
if (filters.category) {
|
|
query += ' AND category = ?';
|
|
params.push(filters.category);
|
|
}
|
|
|
|
if (filters.developmentStyle) {
|
|
query += ' AND development_style = ?';
|
|
params.push(filters.developmentStyle);
|
|
}
|
|
|
|
if (filters.isAITool !== undefined) {
|
|
query += ' AND is_ai_tool = ?';
|
|
params.push(filters.isAITool ? 1 : 0);
|
|
}
|
|
|
|
query += ' ORDER BY display_name';
|
|
|
|
if (filters.limit) {
|
|
query += ' LIMIT ?';
|
|
params.push(filters.limit);
|
|
}
|
|
|
|
const nodes = this.db!.prepare(query).all(...params) as NodeRow[];
|
|
|
|
return {
|
|
nodes: nodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name,
|
|
developmentStyle: node.development_style,
|
|
isAITool: !!node.is_ai_tool,
|
|
isTrigger: !!node.is_trigger,
|
|
isVersioned: !!node.is_versioned,
|
|
})),
|
|
totalCount: nodes.length,
|
|
};
|
|
}
|
|
|
|
private getNodeInfo(nodeType: string): any {
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
let node = this.repository.getNode(nodeType);
|
|
|
|
if (!node) {
|
|
// Try alternative formats
|
|
const alternatives = [
|
|
nodeType,
|
|
nodeType.replace('n8n-nodes-base.', ''),
|
|
`n8n-nodes-base.${nodeType}`,
|
|
nodeType.toLowerCase()
|
|
];
|
|
|
|
for (const alt of alternatives) {
|
|
const found = this.repository!.getNode(alt);
|
|
if (found) {
|
|
node = found;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
private searchNodes(query: string, limit: number = 20): any {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
// Simple search across multiple fields
|
|
const searchQuery = `%${query}%`;
|
|
const nodes = this.db!.prepare(`
|
|
SELECT * FROM nodes
|
|
WHERE node_type LIKE ?
|
|
OR display_name LIKE ?
|
|
OR description LIKE ?
|
|
OR documentation LIKE ?
|
|
ORDER BY
|
|
CASE
|
|
WHEN node_type LIKE ? THEN 1
|
|
WHEN display_name LIKE ? THEN 2
|
|
ELSE 3
|
|
END
|
|
LIMIT ?
|
|
`).all(
|
|
searchQuery, searchQuery, searchQuery, searchQuery,
|
|
searchQuery, searchQuery,
|
|
limit
|
|
) as NodeRow[];
|
|
|
|
return {
|
|
query,
|
|
results: nodes.map(node => ({
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
description: node.description,
|
|
category: node.category,
|
|
package: node.package_name,
|
|
relevance: this.calculateRelevance(node, query),
|
|
})),
|
|
totalCount: nodes.length,
|
|
};
|
|
}
|
|
|
|
private calculateRelevance(node: NodeRow, query: string): string {
|
|
const lowerQuery = query.toLowerCase();
|
|
if (node.node_type.toLowerCase().includes(lowerQuery)) return 'high';
|
|
if (node.display_name.toLowerCase().includes(lowerQuery)) return 'high';
|
|
if (node.description?.toLowerCase().includes(lowerQuery)) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
private listAITools(): any {
|
|
if (!this.repository) throw new Error('Repository not initialized');
|
|
const tools = this.repository.getAITools();
|
|
|
|
return {
|
|
tools,
|
|
totalCount: tools.length,
|
|
requirements: {
|
|
environmentVariable: 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true',
|
|
nodeProperty: 'usableAsTool: true',
|
|
},
|
|
};
|
|
}
|
|
|
|
private getNodeDocumentation(nodeType: string): any {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
const node = this.db!.prepare(`
|
|
SELECT node_type, display_name, documentation
|
|
FROM nodes
|
|
WHERE node_type = ?
|
|
`).get(nodeType) as NodeRow | undefined;
|
|
|
|
if (!node) {
|
|
throw new Error(`Node ${nodeType} not found`);
|
|
}
|
|
|
|
return {
|
|
nodeType: node.node_type,
|
|
displayName: node.display_name,
|
|
documentation: node.documentation || 'No documentation available',
|
|
hasDocumentation: !!node.documentation,
|
|
};
|
|
}
|
|
|
|
private getDatabaseStatistics(): any {
|
|
if (!this.db) throw new Error('Database not initialized');
|
|
const stats = this.db!.prepare(`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(is_ai_tool) as ai_tools,
|
|
SUM(is_trigger) as triggers,
|
|
SUM(is_versioned) as versioned,
|
|
SUM(CASE WHEN documentation IS NOT NULL THEN 1 ELSE 0 END) as with_docs,
|
|
COUNT(DISTINCT package_name) as packages,
|
|
COUNT(DISTINCT category) as categories
|
|
FROM nodes
|
|
`).get() as any;
|
|
|
|
const packages = this.db!.prepare(`
|
|
SELECT package_name, COUNT(*) as count
|
|
FROM nodes
|
|
GROUP BY package_name
|
|
`).all() as any[];
|
|
|
|
return {
|
|
totalNodes: stats.total,
|
|
statistics: {
|
|
aiTools: stats.ai_tools,
|
|
triggers: stats.triggers,
|
|
versionedNodes: stats.versioned,
|
|
nodesWithDocumentation: stats.with_docs,
|
|
documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%',
|
|
uniquePackages: stats.packages,
|
|
uniqueCategories: stats.categories,
|
|
},
|
|
packageBreakdown: packages.map(pkg => ({
|
|
package: pkg.package_name,
|
|
nodeCount: pkg.count,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async run(): Promise<void> {
|
|
// Ensure database is initialized before starting server
|
|
await this.ensureInitialized();
|
|
|
|
const transport = new StdioServerTransport();
|
|
await this.server.connect(transport);
|
|
logger.info('n8n Documentation MCP Server running on stdio transport');
|
|
}
|
|
} |