From f072b2e00384e29e8ee8cc77f11af61c8eeea1fd Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Fri, 10 Oct 2025 09:42:53 +0200 Subject: [PATCH] fix: resolve SQL parsing for triggers in schema initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Issue**: 30 CI tests failing with "incomplete input" database error - tests/unit/mcp/get-node-essentials-examples.test.ts (16 tests) - tests/unit/mcp/search-nodes-examples.test.ts (14 tests) **Root Cause**: Both `src/mcp/server.ts` and `tests/integration/database/test-utils.ts` used naive `schema.split(';')` to parse SQL statements. This breaks trigger definitions containing semicolons inside BEGIN...END blocks: ```sql CREATE TRIGGER nodes_fts_insert AFTER INSERT ON nodes BEGIN INSERT INTO nodes_fts(...) VALUES (...); -- ← semicolon inside block END; ``` Splitting by ';' created incomplete statements, causing SQLite parse errors. **Fix**: - Added `parseSQLStatements()` method to both files - Tracks `inBlock` state when entering BEGIN...END blocks - Only splits on ';' when NOT inside a block - Skips SQL comments and empty lines - Preserves complete trigger definitions **Documentation**: Added clarifying comments to explain FTS5 search architecture: - `NodeRepository.searchNodes()`: Legacy LIKE-based search for direct repository usage - `MCPServer.searchNodes()`: Production FTS5 search used by ALL MCP tools This addresses confusion from code review where FTS5 appeared unused. In reality, FTS5 IS used via MCPServer.searchNodes() (lines 1189-1203). **Verification**: ✅ get-node-essentials-examples.test.ts: 16 tests passed ✅ search-nodes-examples.test.ts: 14 tests passed ✅ CI database validation: 25 tests passed ✅ Build successful with no TypeScript errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/database/node-repository.ts | 14 ++++- src/mcp/server.ts | 76 ++++++++++++++++++++++-- tests/integration/database/test-utils.ts | 58 ++++++++++++++++-- 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/src/database/node-repository.ts b/src/database/node-repository.ts index 7ba8797..845a190 100644 --- a/src/database/node-repository.ts +++ b/src/database/node-repository.ts @@ -123,10 +123,22 @@ export class NodeRepository { return rows.map(row => this.parseNodeRow(row)); } + /** + * Legacy LIKE-based search method for direct repository usage. + * + * NOTE: MCP tools do NOT use this method. They use MCPServer.searchNodes() + * which automatically detects and uses FTS5 full-text search when available. + * See src/mcp/server.ts:1135-1148 for FTS5 implementation. + * + * This method remains for: + * - Direct repository access in scripts/benchmarks + * - Fallback when FTS5 table doesn't exist + * - Legacy compatibility + */ searchNodes(query: string, mode: 'OR' | 'AND' | 'FUZZY' = 'OR', limit: number = 20): any[] { let sql = ''; const params: any[] = []; - + if (mode === 'FUZZY') { // Simple fuzzy search sql = ` diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ba611ba..fc4d67b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -182,19 +182,74 @@ export class N8NDocumentationMCPServer { private async initializeInMemorySchema(): Promise { if (!this.db) return; - + // Read and execute schema const schemaPath = path.join(__dirname, '../../src/database/schema.sql'); const schema = await fs.readFile(schemaPath, 'utf-8'); - - // Execute schema statements - const statements = schema.split(';').filter(stmt => stmt.trim()); + + // Parse SQL statements properly (handles BEGIN...END blocks in triggers) + const statements = this.parseSQLStatements(schema); + for (const statement of statements) { if (statement.trim()) { - this.db.exec(statement); + try { + this.db.exec(statement); + } catch (error) { + logger.error(`Failed to execute SQL statement: ${statement.substring(0, 100)}...`, error); + throw error; + } } } } + + /** + * Parse SQL statements from schema file, properly handling multi-line statements + * including triggers with BEGIN...END blocks + */ + private parseSQLStatements(sql: string): string[] { + const statements: string[] = []; + let current = ''; + let inBlock = false; + + const lines = sql.split('\n'); + + for (const line of lines) { + const trimmed = line.trim().toUpperCase(); + + // Skip comments and empty lines + if (trimmed.startsWith('--') || trimmed === '') { + continue; + } + + // Track BEGIN...END blocks (triggers, procedures) + if (trimmed.includes('BEGIN')) { + inBlock = true; + } + + current += line + '\n'; + + // End of block (trigger/procedure) + if (inBlock && trimmed === 'END;') { + statements.push(current.trim()); + current = ''; + inBlock = false; + continue; + } + + // Regular statement end (not in block) + if (!inBlock && trimmed.endsWith(';')) { + statements.push(current.trim()); + current = ''; + } + } + + // Add any remaining content + if (current.trim()) { + statements.push(current.trim()); + } + + return statements.filter(s => s.length > 0); + } private async ensureInitialized(): Promise { await this.initialized; @@ -1107,6 +1162,15 @@ export class N8NDocumentationMCPServer { }; } + /** + * Primary search method used by ALL MCP search tools. + * + * This method automatically detects and uses FTS5 full-text search when available + * (lines 1189-1203), falling back to LIKE queries only if FTS5 table doesn't exist. + * + * NOTE: This is separate from NodeRepository.searchNodes() which is legacy LIKE-based. + * All MCP tool invocations route through this method to leverage FTS5 performance. + */ private async searchNodes( query: string, limit: number = 20, @@ -1118,7 +1182,7 @@ export class N8NDocumentationMCPServer { ): Promise { await this.ensureInitialized(); if (!this.db) throw new Error('Database not initialized'); - + // Normalize the query if it looks like a full node type let normalizedQuery = query; diff --git a/tests/integration/database/test-utils.ts b/tests/integration/database/test-utils.ts index 5729441..abe1e10 100644 --- a/tests/integration/database/test-utils.ts +++ b/tests/integration/database/test-utils.ts @@ -103,18 +103,64 @@ export class TestDatabase { const schemaPath = path.join(__dirname, '../../../src/database/schema.sql'); const schema = fs.readFileSync(schemaPath, 'utf-8'); - - // Execute schema statements one by one - const statements = schema - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0); + + // Parse SQL statements properly (handles BEGIN...END blocks in triggers) + const statements = this.parseSQLStatements(schema); for (const statement of statements) { this.db.exec(statement); } } + /** + * Parse SQL statements from schema file, properly handling multi-line statements + * including triggers with BEGIN...END blocks + */ + private parseSQLStatements(sql: string): string[] { + const statements: string[] = []; + let current = ''; + let inBlock = false; + + const lines = sql.split('\n'); + + for (const line of lines) { + const trimmed = line.trim().toUpperCase(); + + // Skip comments and empty lines + if (trimmed.startsWith('--') || trimmed === '') { + continue; + } + + // Track BEGIN...END blocks (triggers, procedures) + if (trimmed.includes('BEGIN')) { + inBlock = true; + } + + current += line + '\n'; + + // End of block (trigger/procedure) + if (inBlock && trimmed === 'END;') { + statements.push(current.trim()); + current = ''; + inBlock = false; + continue; + } + + // Regular statement end (not in block) + if (!inBlock && trimmed.endsWith(';')) { + statements.push(current.trim()); + current = ''; + } + } + + // Add any remaining content + if (current.trim()) { + statements.push(current.trim()); + } + + return statements.filter(s => s.length > 0); + } + /** * Gets the underlying better-sqlite3 database instance. * @throws Error if database is not initialized