diff --git a/src/http-server-fixed.ts b/src/http-server-fixed.ts index b19cdc4..6b43121 100644 --- a/src/http-server-fixed.ts +++ b/src/http-server-fixed.ts @@ -98,12 +98,16 @@ export async function startFixedHTTPServer() { next(); }); + // Create a single persistent MCP server instance + const mcpServer = new N8NDocumentationMCPServer(); + logger.info('Created persistent MCP server instance'); + // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'http-fixed', - version: '2.3.2', + version: '2.4.1', uptime: Math.floor(process.uptime()), memory: { used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), @@ -113,10 +117,26 @@ export async function startFixedHTTPServer() { timestamp: new Date().toISOString() }); }); - - // Create a single persistent MCP server instance - const mcpServer = new N8NDocumentationMCPServer(); - logger.info('Created persistent MCP server instance'); + + // Version endpoint + app.get('/version', (req, res) => { + res.json({ + version: '2.4.1', + buildTime: new Date().toISOString(), + tools: n8nDocumentationToolsFinal.map(t => t.name), + commit: process.env.GIT_COMMIT || 'unknown' + }); + }); + + // Test tools endpoint + app.get('/test-tools', async (req, res) => { + try { + const result = await mcpServer.executeTool('get_node_essentials', { nodeType: 'nodes-base.httpRequest' }); + res.json({ status: 'ok', hasData: !!result, toolCount: n8nDocumentationToolsFinal.length }); + } catch (error) { + res.json({ status: 'error', message: error instanceof Error ? error.message : 'Unknown error' }); + } + }); // Main MCP endpoint - handle each request with custom transport handling app.post('/mcp', async (req: express.Request, res: express.Response): Promise => { @@ -174,7 +194,7 @@ export async function startFixedHTTPServer() { }, serverInfo: { name: 'n8n-documentation-mcp', - version: '2.3.2' + version: '2.4.1' } }, id: jsonRpcRequest.id diff --git a/src/mcp/server-update.ts b/src/mcp/server-update.ts index 29d6c54..b4ae4ce 100644 --- a/src/mcp/server-update.ts +++ b/src/mcp/server-update.ts @@ -15,6 +15,7 @@ import { ExampleGenerator } from '../services/example-generator'; import { TaskTemplates } from '../services/task-templates'; import { ConfigValidator } from '../services/config-validator'; import { PropertyDependencies } from '../services/property-dependencies'; +import { SimpleCache } from '../utils/simple-cache'; interface NodeRow { node_type: string; @@ -39,6 +40,7 @@ export class N8NDocumentationMCPServer { private db: DatabaseAdapter | null = null; private repository: NodeRepository | null = null; private initialized: Promise; + private cache = new SimpleCache(); constructor() { // Try multiple database paths @@ -172,10 +174,18 @@ export class N8NDocumentationMCPServer { let query = 'SELECT * FROM nodes WHERE 1=1'; const params: any[] = []; + + console.log('DEBUG list_nodes:', { filters, query, params }); // ADD THIS if (filters.package) { - query += ' AND package_name = ?'; - params.push(filters.package); + // Handle both formats + const packageVariants = [ + filters.package, + `@n8n/${filters.package}`, + filters.package.replace('@n8n/', '') + ]; + query += ' AND package_name IN (' + packageVariants.map(() => '?').join(',') + ')'; + params.push(...packageVariants); } if (filters.category) { @@ -251,26 +261,51 @@ export class N8NDocumentationMCPServer { private async searchNodes(query: string, limit: number = 20): Promise { await this.ensureInitialized(); if (!this.db) throw new Error('Database not initialized'); - // Simple search across multiple fields - const searchQuery = `%${query}%`; + + // Handle exact phrase searches with quotes + if (query.startsWith('"') && query.endsWith('"')) { + const exactPhrase = query.slice(1, -1); + const nodes = this.db!.prepare(` + SELECT * FROM nodes + WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ? + ORDER BY display_name + LIMIT ? + `).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, 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 + })), + totalCount: nodes.length + }; + } + + // Split into words for normal search + const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0); + + if (words.length === 0) { + return { query, results: [], totalCount: 0 }; + } + + // Build conditions for each word + const conditions = words.map(() => + '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)' + ).join(' OR '); + + const params: any[] = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]); + params.push(limit); + 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 + SELECT DISTINCT * FROM nodes + WHERE ${conditions} + ORDER BY display_name LIMIT ? - `).all( - searchQuery, searchQuery, searchQuery, searchQuery, - searchQuery, searchQuery, - limit - ) as NodeRow[]; + `).all(...params) as NodeRow[]; return { query, @@ -279,10 +314,9 @@ export class N8NDocumentationMCPServer { displayName: node.display_name, description: node.description, category: node.category, - package: node.package_name, - relevance: this.calculateRelevance(node, query), + package: node.package_name })), - totalCount: nodes.length, + totalCount: nodes.length }; } @@ -299,6 +333,14 @@ export class N8NDocumentationMCPServer { if (!this.repository) throw new Error('Repository not initialized'); const tools = this.repository.getAITools(); + // Debug: Check if is_ai_tool column is populated + const aiCount = this.db!.prepare('SELECT COUNT(*) as ai_count FROM nodes WHERE is_ai_tool = 1').get() as any; + console.log('DEBUG list_ai_tools:', { + toolsLength: tools.length, + aiCountInDB: aiCount.ai_count, + sampleTools: tools.slice(0, 3) + }); + return { tools, totalCount: tools.length, @@ -313,7 +355,7 @@ export class N8NDocumentationMCPServer { await this.ensureInitialized(); if (!this.db) throw new Error('Database not initialized'); const node = this.db!.prepare(` - SELECT node_type, display_name, documentation + SELECT node_type, display_name, documentation, description FROM nodes WHERE node_type = ? `).get(nodeType) as NodeRow | undefined; @@ -322,11 +364,36 @@ export class N8NDocumentationMCPServer { throw new Error(`Node ${nodeType} not found`); } + // If no documentation, generate fallback + if (!node.documentation) { + const essentials = await this.getNodeEssentials(nodeType); + + return { + nodeType: node.node_type, + displayName: node.display_name, + documentation: ` +# ${node.display_name} + +${node.description || 'No description available.'} + +## Common Properties + +${essentials.commonProperties.map((p: any) => + `### ${p.displayName}\n${p.description || `Type: ${p.type}`}` +).join('\n\n')} + +## Note +Full documentation is being prepared. For now, use get_node_essentials for configuration help. +`, + hasDocumentation: false + }; + } + return { nodeType: node.node_type, displayName: node.display_name, - documentation: node.documentation || 'No documentation available', - hasDocumentation: !!node.documentation, + documentation: node.documentation, + hasDocumentation: true, }; } @@ -373,6 +440,11 @@ export class N8NDocumentationMCPServer { await this.ensureInitialized(); if (!this.repository) throw new Error('Repository not initialized'); + // Check cache first + const cacheKey = `essentials:${nodeType}`; + const cached = this.cache.get(cacheKey); + if (cached) return cached; + // Get the full node information let node = this.repository.getNode(nodeType); @@ -410,7 +482,7 @@ export class N8NDocumentationMCPServer { // Get operations (already parsed by repository) const operations = node.operations || []; - return { + const result = { nodeType: node.nodeType, displayName: node.displayName, description: node.description, @@ -436,6 +508,11 @@ export class N8NDocumentationMCPServer { developmentStyle: node.developmentStyle || 'programmatic' } }; + + // Cache for 1 hour + this.cache.set(cacheKey, result, 3600); + + return result; } private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise { diff --git a/src/services/property-filter.ts b/src/services/property-filter.ts index ae53f41..603c48c 100644 --- a/src/services/property-filter.ts +++ b/src/services/property-filter.ts @@ -176,23 +176,45 @@ export class PropertyFilter { } }; + /** + * Deduplicate properties based on name and display conditions + */ + static deduplicateProperties(properties: any[]): any[] { + const seen = new Map(); + + return properties.filter(prop => { + // Create unique key from name + conditions + const conditions = JSON.stringify(prop.displayOptions || {}); + const key = `${prop.name}_${conditions}`; + + if (seen.has(key)) { + return false; // Skip duplicate + } + + seen.set(key, prop); + return true; + }); + } + /** * Get essential properties for a node type */ static getEssentials(allProperties: any[], nodeType: string): FilteredProperties { + // Deduplicate first + const uniqueProperties = this.deduplicateProperties(allProperties); const config = this.ESSENTIAL_PROPERTIES[nodeType]; if (!config) { // Fallback for unconfigured nodes - return this.inferEssentials(allProperties); + return this.inferEssentials(uniqueProperties); } // Extract required properties - const required = this.extractProperties(allProperties, config.required, true); + const required = this.extractProperties(uniqueProperties, config.required, true); // Extract common properties (excluding any already in required) const requiredNames = new Set(required.map(p => p.name)); - const common = this.extractProperties(allProperties, config.common, false) + const common = this.extractProperties(uniqueProperties, config.common, false) .filter(p => !requiredNames.has(p.name)); return { required, common }; diff --git a/src/utils/simple-cache.ts b/src/utils/simple-cache.ts new file mode 100644 index 0000000..c2d8088 --- /dev/null +++ b/src/utils/simple-cache.ts @@ -0,0 +1,37 @@ +/** + * Simple in-memory cache with TTL support + * No external dependencies needed + */ +export class SimpleCache { + private cache = new Map(); + + constructor() { + // Clean up expired entries every minute + setInterval(() => { + const now = Date.now(); + for (const [key, item] of this.cache.entries()) { + if (item.expires < now) this.cache.delete(key); + } + }, 60000); + } + + get(key: string): any { + const item = this.cache.get(key); + if (!item || item.expires < Date.now()) { + this.cache.delete(key); + return null; + } + return item.data; + } + + set(key: string, data: any, ttlSeconds: number = 300): void { + this.cache.set(key, { + data, + expires: Date.now() + (ttlSeconds * 1000) + }); + } + + clear(): void { + this.cache.clear(); + } +} \ No newline at end of file