From ec3ab74efe4d7146c08ca20dbfce50c80d8531f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Tue, 10 Feb 2026 15:26:51 +1100 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20v1.4.0=20=E2=80=94=20FTS5=20search?= =?UTF-8?q?,=20chunk=20truncation,=20FTS=20rebuild=20on=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch rah_search_nodes and rah_search_content from LIKE to FTS5 with word-split LIKE fallback - Rebuild nodes_fts and chunks_fts indexes on server startup - Cap chunk field at 10K chars in rah_get_nodes (adds chunk_truncated, chunk_length) - Multi-word queries now work ("Tesla manufacturing" finds both words anywhere) - Relevance-ranked results when FTS available Co-Authored-By: Claude Opus 4.6 --- apps/mcp-server-standalone/README.md | 4 +- .../guides/system/schema.md | 29 +- apps/mcp-server-standalone/index.js | 384 +++++++++++++++++- apps/mcp-server-standalone/package.json | 2 +- .../services/sqlite-client.js | 2 +- 5 files changed, 390 insertions(+), 31 deletions(-) diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md index 9d96b9c..d570e7c 100644 --- a/apps/mcp-server-standalone/README.md +++ b/apps/mcp-server-standalone/README.md @@ -53,7 +53,7 @@ Once connected, Claude will: | `rah_get_context` | Get graph overview — stats, hub nodes, dimensions, recent activity | | `rah_add_node` | Create a new node | | `rah_search_nodes` | Search nodes by keyword | -| `rah_get_nodes` | Load nodes by ID | +| `rah_get_nodes` | Load nodes by ID (includes chunk + metadata) | | `rah_update_node` | Update an existing node | | `rah_create_edge` | Create connection between nodes | | `rah_update_edge` | Update an edge explanation | @@ -66,6 +66,8 @@ Once connected, Claude will: | `rah_read_guide` | Read a guide by name | | `rah_write_guide` | Create or update a custom guide | | `rah_delete_guide` | Delete a custom guide | +| `rah_search_content` | Search through source content (transcripts, books, articles) | +| `rah_sqlite_query` | Execute read-only SQL queries (SELECT/WITH/PRAGMA) | ## Guides diff --git a/apps/mcp-server-standalone/guides/system/schema.md b/apps/mcp-server-standalone/guides/system/schema.md index 5c0fa80..61589f8 100644 --- a/apps/mcp-server-standalone/guides/system/schema.md +++ b/apps/mcp-server-standalone/guides/system/schema.md @@ -38,25 +38,27 @@ immutable: true ### dimensions | Column | Type | Notes | |--------|------|-------| -| id | INTEGER | Primary key | -| name | TEXT | Unique, case-insensitive | +| name | TEXT | Primary key | | description | TEXT | Purpose description | -| is_locked | INTEGER | 1 = priority dimension (auto-assigns to new nodes) | +| is_priority | INTEGER | 1 = priority dimension (auto-assigns to new nodes) | +| updated_at | TEXT | ISO timestamp | ### node_dimensions (junction) | Column | Type | |--------|------| | node_id | INTEGER FK → nodes.id | -| dimension_id | INTEGER FK → dimensions.id | +| dimension | TEXT (dimension name) | ### chunks (for semantic search) | Column | Type | Notes | |--------|------|-------| | id | INTEGER | Primary key | | node_id | INTEGER | FK → nodes.id | -| chunk_index | INTEGER | Position in sequence | +| chunk_idx | INTEGER | Position in sequence | | text | TEXT | Chunk content | -| embedding | BLOB | Vector (via sqlite-vec) | +| created_at | TEXT | ISO timestamp | +| embedding_type | TEXT | Embedding model used | +| metadata | TEXT | JSON blob | ### FTS Tables - `chunks_fts` — full-text search on chunk text @@ -76,8 +78,7 @@ GROUP BY n.id ORDER BY edge_count DESC LIMIT 5 ```sql SELECT n.* FROM nodes n JOIN node_dimensions nd ON n.id = nd.node_id -JOIN dimensions d ON nd.dimension_id = d.id -WHERE d.name = ? +WHERE nd.dimension = ? ``` **Edges for a node (both directions):** @@ -89,4 +90,14 @@ JOIN nodes n2 ON e.to_node_id = n2.id WHERE e.from_node_id = ? OR e.to_node_id = ? ``` -**Use sqliteQuery for any read operation not covered by structured tools.** +**Search source content (chunks):** +```sql +SELECT c.id, c.node_id, c.chunk_idx, c.text, n.title +FROM chunks c +JOIN nodes n ON c.node_id = n.id +WHERE c.text LIKE '%search term%' COLLATE NOCASE +ORDER BY c.chunk_idx ASC +LIMIT 10 +``` + +**Use `rah_search_content` to search chunks by keyword, or `rah_sqlite_query` for any read operation not covered by structured tools.** diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index b7400fb..ab8c609 100755 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -5,7 +5,7 @@ const { z } = require('zod'); const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'); -const { initDatabase, getDatabasePath, closeDatabase } = require('./services/sqlite-client'); +const { initDatabase, getDatabasePath, closeDatabase, getDb, query } = require('./services/sqlite-client'); const nodeService = require('./services/nodeService'); const edgeService = require('./services/edgeService'); const dimensionService = require('./services/dimensionService'); @@ -14,7 +14,7 @@ const guideService = require('./services/guideService'); // Server info const serverInfo = { name: 'ra-h-standalone', - version: '1.2.0' + version: '1.4.0' }; const instructions = [ @@ -110,6 +110,17 @@ const deleteGuideInputSchema = { name: z.string().min(1).describe('Guide name to delete') }; +const searchContentInputSchema = { + query: z.string().min(1).max(400).describe('Search text'), + node_id: z.number().int().positive().optional().describe('Scope to a specific node\'s chunks'), + limit: z.number().min(1).max(20).optional().describe('Max results (default 5)') +}; + +const sqliteQueryInputSchema = { + sql: z.string().min(1).describe('The SQL query to execute. Must be a SELECT, WITH, or PRAGMA statement.'), + format: z.enum(['json', 'table']).optional().describe('Output format (default json)') +}; + // Helper to sanitize dimensions function sanitizeDimensions(raw) { if (!Array.isArray(raw)) return []; @@ -128,6 +139,84 @@ function sanitizeDimensions(raw) { return result; } +// FTS5 helpers +function sanitizeFtsQuery(input) { + return input + .replace(/['"()*:^~{}[\]]/g, ' ') + .trim() + .split(/\s+/) + .filter(w => w.length > 0 && !/^(AND|OR|NOT|NEAR)$/i.test(w)) + .join(' '); +} + +let _ftsAvailability = null; + +function checkFtsAvailability() { + if (_ftsAvailability !== null) return _ftsAvailability; + try { + const db = getDb(); + const nodesFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='nodes_fts'").get(); + const chunksFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_fts'").get(); + _ftsAvailability = { nodes: !!nodesFts, chunks: !!chunksFts }; + } catch { + _ftsAvailability = { nodes: false, chunks: false }; + } + return _ftsAvailability; +} + +function rebuildFtsIndexes() { + const fts = checkFtsAvailability(); + if (!fts.nodes && !fts.chunks) return; + + const db = getDb(); + if (fts.nodes) { + try { + db.exec("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')"); + log('Rebuilt nodes_fts index'); + } catch (err) { + log('Warning: Failed to rebuild nodes_fts:', err.message); + _ftsAvailability.nodes = false; + } + } + if (fts.chunks) { + try { + db.exec("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')"); + log('Rebuilt chunks_fts index'); + } catch (err) { + log('Warning: Failed to rebuild chunks_fts:', err.message); + _ftsAvailability.chunks = false; + } + } +} + +// Security: Only allow read-only SQL statements +function isReadOnlyQuery(sql) { + const normalized = sql.trim().toLowerCase(); + + const allowedPrefixes = ['select', 'with', 'pragma']; + const startsWithAllowed = allowedPrefixes.some(prefix => + normalized.startsWith(prefix) + ); + + if (!startsWithAllowed) return false; + + const dangerousPatterns = [ + /\binsert\b/i, + /\bupdate\b/i, + /\bdelete\b/i, + /\bdrop\b/i, + /\bcreate\b/i, + /\balter\b/i, + /\battach\b/i, + /\bdetach\b/i, + /\breindex\b/i, + /\bvacuum\b/i, + /\banalyze\b/i, + ]; + + return !dangerousPatterns.some(pattern => pattern.test(sql)); +} + // Log to stderr (stdout is reserved for MCP protocol) function log(...args) { console.error('[ra-h-standalone]', ...args); @@ -138,6 +227,7 @@ async function main() { try { initDatabase(); log('Database connected:', getDatabasePath()); + rebuildFtsIndexes(); } catch (error) { log('ERROR:', error.message); process.exit(1); @@ -222,17 +312,116 @@ async function main() { 'rah_search_nodes', { title: 'Search RA-H nodes', - description: 'Search nodes by keyword across title, description, and content fields. Prioritizes exact title matches. Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions.', + description: 'Search nodes by keyword across title, description, and content fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions.', inputSchema: searchNodesInputSchema }, - async ({ query, limit = 10, dimensions }) => { + async ({ query: searchQuery, limit = 10, dimensions }) => { const normalizedDimensions = sanitizeDimensions(dimensions || []); + const safeLimit = Math.min(Math.max(limit, 1), 25); + const trimmedQuery = searchQuery.trim(); + const fts = checkFtsAvailability(); - const nodes = nodeService.getNodes({ - search: query.trim(), - limit: Math.min(Math.max(limit, 1), 25), - dimensions: normalizedDimensions.length > 0 ? normalizedDimensions : undefined - }); + let nodes = null; + + // Try FTS5 first (handles multi-word queries naturally) + if (fts.nodes) { + const ftsQuery = sanitizeFtsQuery(trimmedQuery); + if (ftsQuery) { + try { + let sql, params; + + if (normalizedDimensions.length > 0) { + sql = ` + WITH fts_matches AS ( + SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100 + ) + SELECT n.id, n.title, n.description, n.content, n.link, n.updated_at, + COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) + FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json + FROM fts_matches fm + JOIN nodes n ON n.id = fm.rowid + WHERE EXISTS ( + SELECT 1 FROM node_dimensions nd + WHERE nd.node_id = n.id + AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')}) + ) + ORDER BY fm.rank + LIMIT ? + `; + params = [ftsQuery, ...normalizedDimensions, safeLimit]; + } else { + sql = ` + WITH fts_matches AS ( + SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ? + ) + SELECT n.id, n.title, n.description, n.content, n.link, n.updated_at, + COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) + FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json + FROM fts_matches fm + JOIN nodes n ON n.id = fm.rowid + ORDER BY fm.rank + `; + params = [ftsQuery, safeLimit]; + } + + const rows = query(sql, params); + nodes = rows.map(row => ({ + id: row.id, + title: row.title, + content: row.content ?? null, + description: row.description ?? null, + link: row.link ?? null, + dimensions: JSON.parse(row.dimensions_json || '[]'), + updated_at: row.updated_at + })); + } catch (err) { + log('FTS search failed, falling back to LIKE:', err.message); + nodes = null; + } + } + } + + // Fallback: LIKE with word splitting (each word must appear somewhere) + if (nodes === null) { + const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0); + + let sql = ` + SELECT n.id, n.title, n.description, n.content, n.link, n.updated_at, + COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) + FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json + FROM nodes n + WHERE 1=1 + `; + const params = []; + + for (const word of words) { + sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`; + params.push(`%${word}%`, `%${word}%`, `%${word}%`); + } + + if (normalizedDimensions.length > 0) { + sql += ` AND EXISTS ( + SELECT 1 FROM node_dimensions nd + WHERE nd.node_id = n.id + AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')}) + )`; + params.push(...normalizedDimensions); + } + + sql += ` ORDER BY n.updated_at DESC LIMIT ?`; + params.push(safeLimit); + + const rows = query(sql, params); + nodes = rows.map(row => ({ + id: row.id, + title: row.title, + content: row.content ?? null, + description: row.description ?? null, + link: row.link ?? null, + dimensions: JSON.parse(row.dimensions_json || '[]'), + updated_at: row.updated_at + })); + } const summary = nodes.length === 0 ? 'No nodes found matching that query.' @@ -242,15 +431,7 @@ async function main() { content: [{ type: 'text', text: summary }], structuredContent: { count: nodes.length, - nodes: nodes.map(node => ({ - id: node.id, - title: node.title, - content: node.content ?? null, - description: node.description ?? null, - link: node.link ?? null, - dimensions: node.dimensions || [], - updated_at: node.updated_at - })) + nodes } }; } @@ -260,7 +441,7 @@ async function main() { 'rah_get_nodes', { title: 'Get RA-H nodes by ID', - description: 'Load full node records by their IDs (max 10 per call).', + description: 'Load full node records by their IDs (max 10 per call). Chunks over 10K chars are truncated — check chunk_truncated and chunk_length fields. For full text, use rah_search_content to search or rah_sqlite_query with substr() to read sections.', inputSchema: getNodesInputSchema }, async ({ nodeIds }) => { @@ -269,17 +450,25 @@ async function main() { throw new Error('No valid node IDs provided.'); } + const CHUNK_LIMIT = 10000; const nodes = []; for (const id of uniqueIds) { const node = nodeService.getNodeById(id); if (node) { + const rawChunk = node.chunk ?? null; + const chunkTruncated = rawChunk ? rawChunk.length > CHUNK_LIMIT : false; + nodes.push({ id: node.id, title: node.title, content: node.content ?? null, description: node.description ?? null, link: node.link ?? null, + chunk: chunkTruncated ? rawChunk.substring(0, CHUNK_LIMIT) : rawChunk, + chunk_truncated: chunkTruncated, + chunk_length: rawChunk ? rawChunk.length : 0, dimensions: node.dimensions || [], + metadata: node.metadata ?? null, updated_at: node.updated_at }); } @@ -585,6 +774,163 @@ async function main() { } ); + // ========== CONTENT SEARCH TOOL ========== + + server.registerTool( + 'rah_search_content', + { + title: 'Search RA-H source content', + description: 'Search through source content (transcripts, books, articles) stored as chunks. Use when you need to find specific text within a node\'s full source material. For node-level search (titles, descriptions), use rah_search_nodes instead.', + inputSchema: searchContentInputSchema + }, + async ({ query: searchQuery, node_id, limit = 5 }) => { + const db = getDb(); + + // Check if chunks table exists + const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get(); + if (!tableCheck) { + return { + content: [{ type: 'text', text: 'No chunks table found. Source content has not been chunked yet. Use rah_get_nodes to read the raw chunk field instead.' }], + structuredContent: { count: 0, chunks: [], note: 'chunks table does not exist' } + }; + } + + const safeLimit = Math.min(Math.max(limit, 1), 20); + const trimmedQuery = searchQuery.trim(); + const fts = checkFtsAvailability(); + + let results = null; + + // Try FTS5 first (handles multi-word, relevance ranked) + if (fts.chunks) { + const ftsQuery = sanitizeFtsQuery(trimmedQuery); + if (ftsQuery) { + try { + let sql, params; + + if (node_id) { + sql = ` + SELECT c.id, c.node_id, c.chunk_idx, c.text, n.title as node_title + FROM chunks c + JOIN nodes n ON c.node_id = n.id + WHERE c.node_id = ? + AND c.id IN (SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?) + ORDER BY c.chunk_idx ASC + LIMIT ? + `; + params = [node_id, ftsQuery, safeLimit]; + } else { + sql = ` + WITH fts_matches AS ( + SELECT rowid, rank FROM chunks_fts WHERE chunks_fts MATCH ? LIMIT ? + ) + SELECT c.id, c.node_id, c.chunk_idx, c.text, n.title as node_title + FROM fts_matches fm + JOIN chunks c ON c.id = fm.rowid + JOIN nodes n ON c.node_id = n.id + ORDER BY fm.rank + `; + params = [ftsQuery, safeLimit]; + } + + results = query(sql, params); + } catch (err) { + log('FTS content search failed, falling back to LIKE:', err.message); + results = null; + } + } + } + + // Fallback: LIKE with word splitting + if (results === null) { + const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0); + + let sql = ` + SELECT c.id, c.node_id, c.chunk_idx, c.text, n.title as node_title + FROM chunks c + JOIN nodes n ON c.node_id = n.id + WHERE 1=1 + `; + const params = []; + + if (node_id) { + sql += ` AND c.node_id = ?`; + params.push(node_id); + } + + for (const word of words) { + sql += ` AND c.text LIKE ? COLLATE NOCASE`; + params.push(`%${word}%`); + } + + sql += ` ORDER BY n.updated_at DESC, c.chunk_idx ASC LIMIT ?`; + params.push(safeLimit); + + results = query(sql, params); + } + + const summary = results.length === 0 + ? 'No matching content found in chunks.' + : `Found ${results.length} chunk(s) matching "${trimmedQuery}".`; + + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + count: results.length, + chunks: results.map(r => ({ + id: r.id, + node_id: r.node_id, + chunk_idx: r.chunk_idx, + text: r.text, + node_title: r.node_title + })) + } + }; + } + ); + + // ========== SQL QUERY TOOL ========== + + server.registerTool( + 'rah_sqlite_query', + { + title: 'Execute read-only SQL', + description: 'Execute read-only SQL queries against the knowledge graph database. Tables: nodes, edges, dimensions, node_dimensions, chunks. Use PRAGMA table_info(tablename) for schema. Only SELECT/WITH/PRAGMA allowed.', + inputSchema: sqliteQueryInputSchema + }, + async ({ sql: userSql, format = 'json' }) => { + if (!isReadOnlyQuery(userSql)) { + throw new Error('Only SELECT, WITH, and PRAGMA statements are allowed. Write operations must use dedicated tools.'); + } + + try { + const rows = query(userSql); + const rowCount = Array.isArray(rows) ? rows.length : 0; + + if (format === 'table' && Array.isArray(rows) && rows.length > 0) { + // Simple table format + const cols = Object.keys(rows[0]); + const header = cols.join(' | '); + const separator = cols.map(c => '-'.repeat(c.length)).join(' | '); + const body = rows.map(r => cols.map(c => String(r[c] ?? '')).join(' | ')).join('\n'); + const tableStr = `${header}\n${separator}\n${body}`; + + return { + content: [{ type: 'text', text: `${rowCount} row(s).\n\n${tableStr}` }], + structuredContent: { count: rowCount, rows } + }; + } + + return { + content: [{ type: 'text', text: `${rowCount} row(s).` }], + structuredContent: { count: rowCount, rows: Array.isArray(rows) ? rows : [rows] } + }; + } catch (err) { + throw new Error(`SQL error: ${err.message}`); + } + } + ); + // Connect transport const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json index bdb59c2..de9540a 100644 --- a/apps/mcp-server-standalone/package.json +++ b/apps/mcp-server-standalone/package.json @@ -1,6 +1,6 @@ { "name": "ra-h-mcp-server", - "version": "1.2.0", + "version": "1.4.0", "description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.", "main": "index.js", "bin": { diff --git a/apps/mcp-server-standalone/services/sqlite-client.js b/apps/mcp-server-standalone/services/sqlite-client.js index 0a8332d..469618e 100644 --- a/apps/mcp-server-standalone/services/sqlite-client.js +++ b/apps/mcp-server-standalone/services/sqlite-client.js @@ -134,7 +134,7 @@ function query(sql, params = []) { const stmt = database.prepare(sql); const sqlLower = sql.trim().toLowerCase(); - if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.includes('returning')) { + if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.startsWith('pragma') || sqlLower.includes('returning')) { return params.length > 0 ? stmt.all(...params) : stmt.all(); } else { const result = params.length > 0 ? stmt.run(...params) : stmt.run();