diff --git a/apps/mcp-server-standalone/README.md b/apps/mcp-server-standalone/README.md new file mode 100644 index 0000000..069b254 --- /dev/null +++ b/apps/mcp-server-standalone/README.md @@ -0,0 +1,74 @@ +# RA-H MCP Server + +Connect Claude Code and Claude Desktop to your RA-H knowledge base. Direct SQLite access - works without the RA-H app running. + +## Install + +```bash +npx ra-h-mcp-server +``` + +That's it. No manual setup required. + +## Configure Claude Code / Claude Desktop + +Add to your Claude config (`~/.claude.json` or Claude Desktop settings): + +```json +{ + "mcpServers": { + "ra-h": { + "command": "npx", + "args": ["ra-h-mcp-server"] + } + } +} +``` + +Restart Claude. Done. + +## Requirements + +- Node.js 18+ +- RA-H database at `~/Library/Application Support/RA-H/db/rah.sqlite` + - Run the RA-H desktop app once to create it + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `RAH_DB_PATH` | ~/Library/Application Support/RA-H/db/rah.sqlite | Database path | + +## Available Tools + +| Tool | Description | +|------|-------------| +| `rah_add_node` | Create a new node | +| `rah_search_nodes` | Search nodes by keyword | +| `rah_get_nodes` | Load nodes by ID | +| `rah_update_node` | Update an existing node | +| `rah_create_edge` | Create connection between nodes | +| `rah_query_edges` | Find edges for a node | +| `rah_list_dimensions` | List all dimensions | +| `rah_create_dimension` | Create a dimension | +| `rah_update_dimension` | Update/rename a dimension | +| `rah_delete_dimension` | Delete a dimension | + +## What's NOT Included + +This is a lightweight CRUD server. Advanced features are handled by the main app: + +- Embedding generation +- AI-powered edge inference +- Content extraction (URL, YouTube, PDF) +- Real-time SSE events + +## Testing + +```bash +# Test database connection +node -e "const {initDatabase,query}=require('./services/sqlite-client');initDatabase();console.log(query('SELECT COUNT(*) as c FROM nodes')[0].c,'nodes')" + +# Run the server +node index.js +``` diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js new file mode 100644 index 0000000..d943c75 --- /dev/null +++ b/apps/mcp-server-standalone/index.js @@ -0,0 +1,437 @@ +#!/usr/bin/env node +'use strict'; + +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 nodeService = require('./services/nodeService'); +const edgeService = require('./services/edgeService'); +const dimensionService = require('./services/dimensionService'); + +// Server info +const serverInfo = { + name: 'ra-h-standalone', + version: '1.0.0' +}; + +const instructions = [ + 'RA-H Knowledge Base - Direct SQLite access.', + 'Use rah_add_node to create nodes with dimensions.', + 'Use rah_search_nodes to find existing content before creating duplicates.', + 'All operations happen locally on this device.' +].join(' '); + +// Tool schemas +const addNodeInputSchema = { + title: z.string().min(1).max(160).describe('Node title'), + content: z.string().max(20000).optional().describe('Node content/notes'), + link: z.string().url().optional().describe('Source URL'), + description: z.string().max(2000).optional().describe('Brief description'), + dimensions: z.array(z.string()).min(1).max(5).describe('Categories/tags (1-5 required)'), + metadata: z.record(z.any()).optional().describe('Additional metadata'), + chunk: z.string().max(50000).optional().describe('Full source text') +}; + +const searchNodesInputSchema = { + query: z.string().min(1).max(400).describe('Search query'), + limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'), + dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions') +}; + +const getNodesInputSchema = { + nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load') +}; + +const updateNodeInputSchema = { + id: z.number().int().positive().describe('Node ID'), + updates: z.object({ + title: z.string().optional().describe('New title'), + content: z.string().optional().describe('Content to APPEND'), + link: z.string().optional().describe('New link'), + dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), + metadata: z.record(z.any()).optional().describe('New metadata') + }).describe('Fields to update') +}; + +const createEdgeInputSchema = { + sourceId: z.number().int().positive().describe('Source node ID'), + targetId: z.number().int().positive().describe('Target node ID'), + explanation: z.string().min(1).describe('Why does this connection exist?') +}; + +const queryEdgesInputSchema = { + nodeId: z.number().int().positive().optional().describe('Find edges for this node'), + limit: z.number().min(1).max(50).optional().describe('Max edges (default 25)') +}; + +const listDimensionsInputSchema = {}; + +const createDimensionInputSchema = { + name: z.string().min(1).describe('Dimension name'), + description: z.string().max(500).optional().describe('Description'), + isPriority: z.boolean().optional().describe('Lock for auto-assignment') +}; + +const updateDimensionInputSchema = { + name: z.string().min(1).describe('Current dimension name'), + newName: z.string().optional().describe('New name (for renaming)'), + description: z.string().max(500).optional().describe('New description'), + isPriority: z.boolean().optional().describe('Lock/unlock dimension') +}; + +const deleteDimensionInputSchema = { + name: z.string().min(1).describe('Dimension name to delete') +}; + +// Helper to sanitize dimensions +function sanitizeDimensions(raw) { + if (!Array.isArray(raw)) return []; + const result = []; + const seen = new Set(); + for (const value of raw) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (!trimmed) continue; + const lowered = trimmed.toLowerCase(); + if (seen.has(lowered)) continue; + seen.add(lowered); + result.push(trimmed); + if (result.length >= 5) break; + } + return result; +} + +// Log to stderr (stdout is reserved for MCP protocol) +function log(...args) { + console.error('[ra-h-standalone]', ...args); +} + +async function main() { + // Initialize database + try { + initDatabase(); + log('Database connected:', getDatabasePath()); + } catch (error) { + log('ERROR:', error.message); + process.exit(1); + } + + const server = new McpServer(serverInfo, { instructions }); + + // ========== NODE TOOLS ========== + + server.registerTool( + 'rah_add_node', + { + title: 'Add RA-H node', + description: 'Create a new node in the local knowledge base.', + inputSchema: addNodeInputSchema + }, + async ({ title, content, link, description, dimensions, metadata, chunk }) => { + const normalizedDimensions = sanitizeDimensions(dimensions); + if (normalizedDimensions.length === 0) { + throw new Error('At least one dimension is required.'); + } + + const node = nodeService.createNode({ + title: title.trim(), + content: content?.trim(), + link: link?.trim(), + description: description?.trim(), + dimensions: normalizedDimensions, + metadata: metadata || {}, + chunk: chunk?.trim() + }); + + const summary = `Created node #${node.id}: ${node.title} [${node.dimensions.join(', ')}]`; + + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + nodeId: node.id, + title: node.title, + dimensions: node.dimensions, + message: summary + } + }; + } + ); + + server.registerTool( + 'rah_search_nodes', + { + title: 'Search RA-H nodes', + description: 'Find existing nodes by keyword search.', + inputSchema: searchNodesInputSchema + }, + async ({ query, limit = 10, dimensions }) => { + const normalizedDimensions = sanitizeDimensions(dimensions || []); + + const nodes = nodeService.getNodes({ + search: query.trim(), + limit: Math.min(Math.max(limit, 1), 25), + dimensions: normalizedDimensions.length > 0 ? normalizedDimensions : undefined + }); + + const summary = nodes.length === 0 + ? 'No nodes found matching that query.' + : `Found ${nodes.length} node(s).`; + + return { + 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 + })) + } + }; + } + ); + + server.registerTool( + 'rah_get_nodes', + { + title: 'Get RA-H nodes by ID', + description: 'Load full node records by their IDs.', + inputSchema: getNodesInputSchema + }, + async ({ nodeIds }) => { + const uniqueIds = [...new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0))]; + if (uniqueIds.length === 0) { + throw new Error('No valid node IDs provided.'); + } + + const nodes = []; + for (const id of uniqueIds) { + const node = nodeService.getNodeById(id); + if (node) { + nodes.push({ + 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 + }); + } + } + + return { + content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }], + structuredContent: { + count: nodes.length, + nodes + } + }; + } + ); + + server.registerTool( + 'rah_update_node', + { + title: 'Update RA-H node', + description: 'Update an existing node. Content is APPENDED, dimensions are replaced.', + inputSchema: updateNodeInputSchema + }, + async ({ id, updates }) => { + if (!updates || Object.keys(updates).length === 0) { + throw new Error('At least one field must be provided in updates.'); + } + + const node = nodeService.updateNode(id, updates, { appendContent: true }); + + return { + content: [{ type: 'text', text: `Updated node #${id}` }], + structuredContent: { + success: true, + nodeId: node.id, + message: `Updated node #${id}` + } + }; + } + ); + + // ========== EDGE TOOLS ========== + + server.registerTool( + 'rah_create_edge', + { + title: 'Create RA-H edge', + description: 'Create a connection between two nodes.', + inputSchema: createEdgeInputSchema + }, + async ({ sourceId, targetId, explanation }) => { + const edge = edgeService.createEdge({ + from_node_id: sourceId, + to_node_id: targetId, + explanation: explanation.trim(), + source: 'mcp' + }); + + return { + content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }], + structuredContent: { + success: true, + edgeId: edge.id, + message: `Created edge from #${sourceId} to #${targetId}` + } + }; + } + ); + + server.registerTool( + 'rah_query_edges', + { + title: 'Query RA-H edges', + description: 'Find connections between nodes.', + inputSchema: queryEdgesInputSchema + }, + async ({ nodeId, limit = 25 }) => { + const edges = edgeService.getEdges({ + nodeId, + limit: Math.min(Math.max(limit, 1), 50) + }); + + return { + content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }], + structuredContent: { + count: edges.length, + edges: edges.map(e => ({ + id: e.id, + from_node_id: e.from_node_id, + to_node_id: e.to_node_id, + type: e.context?.type ?? null, + explanation: e.context?.explanation ?? null + })) + } + }; + } + ); + + // ========== DIMENSION TOOLS ========== + + server.registerTool( + 'rah_list_dimensions', + { + title: 'List RA-H dimensions', + description: 'Get all dimensions with node counts.', + inputSchema: listDimensionsInputSchema + }, + async () => { + const dimensions = dimensionService.getDimensions(); + + return { + content: [{ type: 'text', text: `Found ${dimensions.length} dimension(s).` }], + structuredContent: { + count: dimensions.length, + dimensions + } + }; + } + ); + + server.registerTool( + 'rah_create_dimension', + { + title: 'Create RA-H dimension', + description: 'Create a new dimension/category.', + inputSchema: createDimensionInputSchema + }, + async ({ name, description, isPriority }) => { + const dimension = dimensionService.createDimension({ + name, + description, + isPriority + }); + + return { + content: [{ type: 'text', text: `Created dimension: ${dimension.dimension}` }], + structuredContent: { + success: true, + dimension: dimension.dimension, + message: `Created dimension: ${dimension.dimension}` + } + }; + } + ); + + server.registerTool( + 'rah_update_dimension', + { + title: 'Update RA-H dimension', + description: 'Update or rename a dimension.', + inputSchema: updateDimensionInputSchema + }, + async ({ name, newName, description, isPriority }) => { + const result = dimensionService.updateDimension({ + name, + currentName: name, + newName, + description, + isPriority + }); + + return { + content: [{ type: 'text', text: `Updated dimension: ${result.dimension}` }], + structuredContent: { + success: true, + dimension: result.dimension, + message: `Updated dimension: ${result.dimension}` + } + }; + } + ); + + server.registerTool( + 'rah_delete_dimension', + { + title: 'Delete RA-H dimension', + description: 'Delete a dimension and remove it from all nodes.', + inputSchema: deleteDimensionInputSchema + }, + async ({ name }) => { + const result = dimensionService.deleteDimension(name); + + return { + content: [{ type: 'text', text: `Deleted dimension: ${name}` }], + structuredContent: { + success: true, + message: `Deleted dimension: ${name}` + } + }; + } + ); + + // Connect transport + const transport = new StdioServerTransport(); + await server.connect(transport); + log('MCP server ready'); + + // Handle graceful shutdown + process.on('SIGINT', () => { + log('Shutting down...'); + closeDatabase(); + process.exit(0); + }); + + process.on('SIGTERM', () => { + log('Shutting down...'); + closeDatabase(); + process.exit(0); + }); +} + +main().catch((error) => { + console.error('[ra-h-standalone] Fatal error:', error); + process.exit(1); +}); diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json new file mode 100644 index 0000000..c0a04b8 --- /dev/null +++ b/apps/mcp-server-standalone/package.json @@ -0,0 +1,48 @@ +{ + "name": "ra-h-mcp-server", + "version": "1.0.0", + "description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.", + "main": "index.js", + "bin": { + "ra-h-mcp-server": "./index.js" + }, + "scripts": { + "start": "node index.js" + }, + "keywords": [ + "mcp", + "claude", + "claude-code", + "claude-desktop", + "knowledge-base", + "knowledge-management", + "sqlite", + "ra-h", + "rah", + "pkm", + "second-brain" + ], + "author": "Bradley Morris ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bradwmorris/ra-h" + }, + "homepage": "https://ra-h.app", + "bugs": { + "url": "https://github.com/bradwmorris/ra-h/issues" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "better-sqlite3": "^11.0.0", + "zod": "^3.23.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "index.js", + "services/", + "README.md" + ] +} diff --git a/apps/mcp-server-standalone/services/dimensionService.js b/apps/mcp-server-standalone/services/dimensionService.js new file mode 100644 index 0000000..ea2fc42 --- /dev/null +++ b/apps/mcp-server-standalone/services/dimensionService.js @@ -0,0 +1,202 @@ +'use strict'; + +const { query, transaction, getDb } = require('./sqlite-client'); + +/** + * Get all dimensions with counts. + */ +function getDimensions() { + const sql = ` + WITH dimension_counts AS ( + SELECT nd.dimension, COUNT(*) AS count + FROM node_dimensions nd + GROUP BY nd.dimension + ) + SELECT + d.name AS dimension, + d.description, + d.is_priority AS isPriority, + COALESCE(dc.count, 0) AS count + FROM dimensions d + LEFT JOIN dimension_counts dc ON dc.dimension = d.name + ORDER BY d.is_priority DESC, d.name ASC + `; + + const rows = query(sql); + + return rows.map(row => ({ + dimension: row.dimension, + description: row.description, + isPriority: Boolean(row.isPriority), + count: Number(row.count) + })); +} + +/** + * Create or update a dimension. + */ +function createDimension(data) { + const { name, description, isPriority = false } = data; + const db = getDb(); + + if (!name || !name.trim()) { + throw new Error('Dimension name is required'); + } + + const trimmedName = name.trim(); + + const stmt = db.prepare(` + INSERT INTO dimensions(name, description, is_priority, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET + description = COALESCE(?, description), + is_priority = COALESCE(?, is_priority), + updated_at = CURRENT_TIMESTAMP + RETURNING name, description, is_priority + `); + + const rows = stmt.all( + trimmedName, + description ?? null, + isPriority ? 1 : 0, + description ?? null, + isPriority ? 1 : 0 + ); + + if (rows.length === 0) { + throw new Error('Failed to create dimension'); + } + + return { + dimension: rows[0].name, + description: rows[0].description, + isPriority: Boolean(rows[0].is_priority) + }; +} + +/** + * Update a dimension. + */ +function updateDimension(data) { + const { name, currentName, newName, description, isPriority } = data; + const db = getDb(); + + // Handle rename + if (currentName && newName && currentName !== newName) { + // Check if new name already exists + const existing = query('SELECT name FROM dimensions WHERE name = ?', [newName]); + if (existing.length > 0) { + throw new Error('A dimension with this name already exists'); + } + + transaction(() => { + // Update dimensions table + const dimStmt = db.prepare(` + UPDATE dimensions + SET name = ?, updated_at = CURRENT_TIMESTAMP + WHERE name = ? + `); + const dimResult = dimStmt.run(newName, currentName); + + if (dimResult.changes === 0) { + throw new Error('Dimension not found'); + } + + // Update node_dimensions + const nodeDimStmt = db.prepare(` + UPDATE node_dimensions + SET dimension = ? + WHERE dimension = ? + `); + nodeDimStmt.run(newName, currentName); + }); + + return { + dimension: newName, + previousName: currentName, + renamed: true + }; + } + + // Handle update (description/isPriority) + const targetName = name || currentName; + if (!targetName) { + throw new Error('Dimension name is required'); + } + + const updates = []; + const params = []; + + if (description !== undefined) { + updates.push('description = ?'); + params.push(description); + } + + if (isPriority !== undefined) { + updates.push('is_priority = ?'); + params.push(isPriority ? 1 : 0); + } + + if (updates.length === 0) { + throw new Error('At least one update field must be provided'); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(targetName); + + const stmt = db.prepare(` + UPDATE dimensions + SET ${updates.join(', ')} + WHERE name = ? + `); + + const result = stmt.run(...params); + + if (result.changes === 0) { + throw new Error('Dimension not found'); + } + + return { + dimension: targetName, + description, + isPriority + }; +} + +/** + * Delete a dimension. + */ +function deleteDimension(name) { + const db = getDb(); + + if (!name || !name.trim()) { + throw new Error('Dimension name is required'); + } + + const removal = transaction(() => { + const nodeDimStmt = db.prepare('DELETE FROM node_dimensions WHERE dimension = ?'); + const dimStmt = db.prepare('DELETE FROM dimensions WHERE name = ?'); + + const removedLinks = nodeDimStmt.run(name).changes ?? 0; + const removedRow = dimStmt.run(name).changes ?? 0; + + return { removedLinks, removedRow }; + }); + + if (!removal.removedLinks && !removal.removedRow) { + throw new Error('Dimension not found'); + } + + return { + dimension: name, + deleted: true, + removedLinks: removal.removedLinks + }; +} + +module.exports = { + getDimensions, + createDimension, + updateDimension, + deleteDimension +}; diff --git a/apps/mcp-server-standalone/services/edgeService.js b/apps/mcp-server-standalone/services/edgeService.js new file mode 100644 index 0000000..aa689ce --- /dev/null +++ b/apps/mcp-server-standalone/services/edgeService.js @@ -0,0 +1,206 @@ +'use strict'; + +const { query, getDb } = require('./sqlite-client'); + +/** + * Get all edges. + */ +function getEdges(filters = {}) { + const { nodeId, limit = 50 } = filters; + + let sql = 'SELECT * FROM edges'; + const params = []; + + if (nodeId) { + sql += ' WHERE from_node_id = ? OR to_node_id = ?'; + params.push(nodeId, nodeId); + } + + sql += ' ORDER BY created_at DESC LIMIT ?'; + params.push(limit); + + const rows = query(sql, params); + + return rows.map(row => ({ + ...row, + context: parseContext(row.context) + })); +} + +/** + * Get edge by ID. + */ +function getEdgeById(id) { + const rows = query('SELECT * FROM edges WHERE id = ?', [id]); + if (rows.length === 0) return null; + + const row = rows[0]; + return { + ...row, + context: parseContext(row.context) + }; +} + +/** + * Create a new edge. + * Note: This is a simplified version without AI inference. + * The main app handles edge type inference. + */ +function createEdge(edgeData) { + const { from_node_id, to_node_id, explanation, source = 'mcp' } = edgeData; + const now = new Date().toISOString(); + const db = getDb(); + + if (!from_node_id || !to_node_id) { + throw new Error('from_node_id and to_node_id are required'); + } + + if (!explanation || !explanation.trim()) { + throw new Error('Edge explanation is required'); + } + + // Simple context without AI inference + // The main app can re-infer types when it loads + const context = { + type: 'related_to', + confidence: 0.5, + inferred_at: now, + explanation: explanation.trim(), + created_via: 'mcp' + }; + + const stmt = db.prepare(` + INSERT INTO edges (from_node_id, to_node_id, context, source, created_at) + VALUES (?, ?, ?, ?, ?) + `); + + const result = stmt.run( + from_node_id, + to_node_id, + JSON.stringify(context), + source, + now + ); + + const edgeId = Number(result.lastInsertRowid); + return getEdgeById(edgeId); +} + +/** + * Update an edge. + */ +function updateEdge(id, updates) { + const { explanation, context: contextUpdates } = updates; + const db = getDb(); + + const existing = getEdgeById(id); + if (!existing) { + throw new Error(`Edge with ID ${id} not found`); + } + + // If explanation changed, update context + if (explanation && explanation.trim()) { + const now = new Date().toISOString(); + const newContext = { + ...existing.context, + explanation: explanation.trim(), + inferred_at: now, + created_via: 'mcp' + }; + + const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?'); + stmt.run(JSON.stringify(newContext), id); + } else if (contextUpdates) { + const newContext = { + ...existing.context, + ...contextUpdates + }; + const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?'); + stmt.run(JSON.stringify(newContext), id); + } + + return getEdgeById(id); +} + +/** + * Delete an edge. + */ +function deleteEdge(id) { + const result = query('DELETE FROM edges WHERE id = ?', [id]); + if (result.changes === 0) { + throw new Error(`Edge with ID ${id} not found`); + } + return true; +} + +/** + * Get connections for a node. + */ +function getNodeConnections(nodeId) { + const sql = ` + SELECT + e.*, + CASE + WHEN e.from_node_id = ? THEN n_to.id + ELSE n_from.id + END as connected_node_id, + CASE + WHEN e.from_node_id = ? THEN n_to.title + ELSE n_from.title + END as connected_node_title, + CASE + WHEN e.from_node_id = ? THEN n_to.description + ELSE n_from.description + END as connected_node_description + FROM edges e + LEFT JOIN nodes n_from ON e.from_node_id = n_from.id + LEFT JOIN nodes n_to ON e.to_node_id = n_to.id + WHERE e.from_node_id = ? OR e.to_node_id = ? + ORDER BY e.created_at DESC + `; + + const rows = query(sql, [nodeId, nodeId, nodeId, nodeId, nodeId]); + + return rows.map(row => ({ + edgeId: row.id, + from_node_id: row.from_node_id, + to_node_id: row.to_node_id, + context: parseContext(row.context), + connected_node: { + id: row.connected_node_id, + title: row.connected_node_title, + description: row.connected_node_description + } + })); +} + +/** + * Get edge count. + */ +function getEdgeCount() { + const rows = query('SELECT COUNT(*) as count FROM edges'); + return Number(rows[0].count); +} + +/** + * Parse context JSON safely. + */ +function parseContext(context) { + if (!context) return null; + if (typeof context === 'object') return context; + try { + return JSON.parse(context); + } catch { + return context; + } +} + +module.exports = { + getEdges, + getEdgeById, + createEdge, + updateEdge, + deleteEdge, + getNodeConnections, + getEdgeCount +}; diff --git a/apps/mcp-server-standalone/services/nodeService.js b/apps/mcp-server-standalone/services/nodeService.js new file mode 100644 index 0000000..668e5c5 --- /dev/null +++ b/apps/mcp-server-standalone/services/nodeService.js @@ -0,0 +1,251 @@ +'use strict'; + +const { query, transaction, getDb } = require('./sqlite-client'); + +/** + * Get nodes with optional filtering. + */ +function getNodes(filters = {}) { + const { dimensions, search, limit = 100, offset = 0 } = filters; + + let sql = ` + SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk, + n.created_at, 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 = []; + + // Filter by dimensions + if (dimensions && dimensions.length > 0) { + sql += ` AND EXISTS ( + SELECT 1 FROM node_dimensions nd + WHERE nd.node_id = n.id + AND nd.dimension IN (${dimensions.map(() => '?').join(',')}) + )`; + params.push(...dimensions); + } + + // Text search + if (search) { + sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.content LIKE ? COLLATE NOCASE)`; + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + } + + // Sort by search relevance or updated_at + if (search) { + sql += ` ORDER BY + CASE WHEN LOWER(n.title) = LOWER(?) THEN 1 ELSE 6 END, + CASE WHEN LOWER(n.title) LIKE LOWER(?) THEN 2 ELSE 6 END, + CASE WHEN n.title LIKE ? COLLATE NOCASE THEN 3 ELSE 6 END, + CASE WHEN n.description LIKE ? COLLATE NOCASE THEN 4 ELSE 6 END, + n.updated_at DESC`; + params.push(search, `${search}%`, `%${search}%`, `%${search}%`); + } else { + sql += ' ORDER BY n.updated_at DESC'; + } + + sql += ` LIMIT ?`; + params.push(limit); + + if (offset > 0) { + sql += ` OFFSET ?`; + params.push(offset); + } + + const rows = query(sql, params); + + return rows.map(row => ({ + ...row, + dimensions: JSON.parse(row.dimensions_json || '[]'), + metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, + dimensions_json: undefined + })); +} + +/** + * Get a single node by ID. + */ +function getNodeById(id) { + const sql = ` + SELECT n.id, n.title, n.description, n.content, n.link, n.type, n.metadata, n.chunk, + n.created_at, 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 n.id = ? + `; + + const rows = query(sql, [id]); + if (rows.length === 0) return null; + + const row = rows[0]; + return { + ...row, + dimensions: JSON.parse(row.dimensions_json || '[]'), + metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null, + dimensions_json: undefined + }; +} + +/** + * Create a new node. + */ +function createNode(nodeData) { + const { + title, + description, + content, + link, + type, + dimensions = [], + chunk, + metadata = {} + } = nodeData; + + const now = new Date().toISOString(); + const db = getDb(); + + const nodeId = transaction(() => { + const stmt = db.prepare(` + INSERT INTO nodes (title, description, content, link, type, metadata, chunk, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const result = stmt.run( + title, + description ?? null, + content ?? null, + link ?? null, + type ?? null, + JSON.stringify(metadata), + chunk ?? null, + now, + now + ); + + const id = Number(result.lastInsertRowid); + + // Insert dimensions + if (dimensions.length > 0) { + const dimStmt = db.prepare( + 'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)' + ); + for (const dimension of dimensions) { + dimStmt.run(id, dimension); + } + } + + return id; + }); + + return getNodeById(nodeId); +} + +/** + * Update an existing node. + * Note: content is APPENDED by default (MCP tool behavior), not replaced. + */ +function updateNode(id, updates, options = {}) { + const { appendContent = true } = options; + const { title, description, content, link, type, dimensions, chunk, metadata } = updates; + const now = new Date().toISOString(); + const db = getDb(); + + // Check node exists + const existing = getNodeById(id); + if (!existing) { + throw new Error(`Node with ID ${id} not found`); + } + + transaction(() => { + const setFields = []; + const params = []; + + if (title !== undefined) { + setFields.push('title = ?'); + params.push(title); + } + if (description !== undefined) { + setFields.push('description = ?'); + params.push(description); + } + if (content !== undefined) { + if (appendContent && existing.content) { + // Append to existing content + setFields.push('content = ?'); + params.push(existing.content + '\n\n' + content); + } else { + setFields.push('content = ?'); + params.push(content); + } + } + if (link !== undefined) { + setFields.push('link = ?'); + params.push(link); + } + if (type !== undefined) { + setFields.push('type = ?'); + params.push(type); + } + if (chunk !== undefined) { + setFields.push('chunk = ?'); + params.push(chunk); + } + if (metadata !== undefined) { + setFields.push('metadata = ?'); + params.push(JSON.stringify(metadata)); + } + + // Always update timestamp + setFields.push('updated_at = ?'); + params.push(now); + params.push(id); + + if (setFields.length > 1) { + const stmt = db.prepare(`UPDATE nodes SET ${setFields.join(', ')} WHERE id = ?`); + stmt.run(...params); + } + + // Handle dimensions separately + if (Array.isArray(dimensions)) { + db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id); + const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'); + for (const dim of dimensions) { + dimStmt.run(id, dim); + } + } + }); + + return getNodeById(id); +} + +/** + * Delete a node. + */ +function deleteNode(id) { + const result = query('DELETE FROM nodes WHERE id = ?', [id]); + if (result.changes === 0) { + throw new Error(`Node with ID ${id} not found`); + } + return true; +} + +/** + * Get node count. + */ +function getNodeCount() { + const rows = query('SELECT COUNT(*) as count FROM nodes'); + return Number(rows[0].count); +} + +module.exports = { + getNodes, + getNodeById, + createNode, + updateNode, + deleteNode, + getNodeCount +}; diff --git a/apps/mcp-server-standalone/services/sqlite-client.js b/apps/mcp-server-standalone/services/sqlite-client.js new file mode 100644 index 0000000..21c7bea --- /dev/null +++ b/apps/mcp-server-standalone/services/sqlite-client.js @@ -0,0 +1,117 @@ +'use strict'; + +const Database = require('better-sqlite3'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +/** + * Get the database path. + * Priority: RAH_DB_PATH env var > default app data location + */ +function getDatabasePath() { + if (process.env.RAH_DB_PATH) { + return process.env.RAH_DB_PATH; + } + + // Default: ~/Library/Application Support/RA-H/db/rah.sqlite + return path.join( + os.homedir(), + 'Library', + 'Application Support', + 'RA-H', + 'db', + 'rah.sqlite' + ); +} + +let db = null; + +/** + * Initialize the database connection. + * Call this once at startup. + */ +function initDatabase() { + if (db) { + return db; + } + + const dbPath = getDatabasePath(); + + // Check if database exists + if (!fs.existsSync(dbPath)) { + throw new Error( + `Database not found at: ${dbPath}\n\n` + + `Have you run RA-H at least once? The database is created when you first launch the app.\n\n` + + `If your database is in a different location, set the RAH_DB_PATH environment variable.` + ); + } + + db = new Database(dbPath); + + // Configure SQLite for performance + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.pragma('cache_size = 5000'); + db.pragma('busy_timeout = 5000'); + + return db; +} + +/** + * Get the database instance. + * Throws if not initialized. + */ +function getDb() { + if (!db) { + throw new Error('Database not initialized. Call initDatabase() first.'); + } + return db; +} + +/** + * Execute a query and return rows. + */ +function query(sql, params = []) { + const database = getDb(); + const stmt = database.prepare(sql); + + const sqlLower = sql.trim().toLowerCase(); + if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.includes('returning')) { + return params.length > 0 ? stmt.all(...params) : stmt.all(); + } else { + const result = params.length > 0 ? stmt.run(...params) : stmt.run(); + return { + changes: result.changes, + lastInsertRowid: Number(result.lastInsertRowid) + }; + } +} + +/** + * Execute a query in a transaction. + */ +function transaction(callback) { + const database = getDb(); + const txn = database.transaction(callback); + return txn(); +} + +/** + * Close the database connection. + */ +function closeDatabase() { + if (db) { + db.close(); + db = null; + } +} + +module.exports = { + initDatabase, + getDb, + query, + transaction, + closeDatabase, + getDatabasePath +}; diff --git a/docs/8_mcp.md b/docs/8_mcp.md index 03dc39b..8b0cc85 100644 --- a/docs/8_mcp.md +++ b/docs/8_mcp.md @@ -1,16 +1,75 @@ # MCP Server -> How to connect Claude Code and other AI assistants to your knowledge base. +> Connect Claude Code and other AI assistants to your knowledge base. -**How it works:** RA-OS runs a local MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your notes, add new knowledge, and manage your knowledge graph. Everything stays local; nothing goes to the cloud. +**How it works:** RA-OS includes an MCP (Model Context Protocol) server. This lets any MCP-compatible assistant — like Claude Code — search your notes, add new knowledge, and manage your knowledge graph. Everything stays local. --- -## Quick Start +## Quick Start (Recommended) + +The easiest way is using the npm package: + +```json +{ + "mcpServers": { + "ra-h": { + "command": "npx", + "args": ["ra-h-mcp-server"] + } + } +} +``` + +Add this to your `~/.claude.json` (Claude Code) or Claude Desktop settings. + +**Requirements:** +- Node.js 18+ installed +- RA-OS run at least once (to create the database) + +**That's it.** No need to keep RA-OS running. + +--- + +## Alternative: Local Development + +If you're developing RA-OS and want to use the local server: + +### Standalone (No Web App Required) + +```json +{ + "mcpServers": { + "ra-h": { + "command": "node", + "args": ["/path/to/ra-h_os/apps/mcp-server-standalone/index.js"] + } + } +} +``` + +First install dependencies: +```bash +cd apps/mcp-server-standalone +npm install +``` + +### HTTP Transport (Web App Required) + +If you want real-time UI updates when nodes are created: 1. Start RA-OS: `npm run dev` -2. Configure your AI assistant (see below) -3. Use naturally: "Search RA-H for my notes on X" or "Add this to RA-H" +2. Configure: + +```json +{ + "mcpServers": { + "ra-h": { + "url": "http://127.0.0.1:44145/mcp" + } + } +} +``` --- @@ -24,55 +83,10 @@ | `rah_get_nodes` | Get nodes by ID | | `rah_create_edge` | Create relationship between nodes | | `rah_query_edges` | Query existing edges | -| `rah_update_edge` | Update edge metadata | +| `rah_list_dimensions` | List all dimensions | | `rah_create_dimension` | Create a new dimension | -| `rah_update_dimension` | Update dimension description | +| `rah_update_dimension` | Update/rename dimension | | `rah_delete_dimension` | Delete a dimension | -| `rah_search_embeddings` | Semantic search across embeddings | - ---- - -## Claude Code Configuration - -Add to your `~/.claude.json` or Claude Code settings: - -```json -{ - "mcpServers": { - "ra-h": { - "command": "node", - "args": ["/path/to/ra-h_os/apps/mcp-server/stdio-server.js"] - } - } -} -``` - -Replace `/path/to/ra-h_os` with the actual path to your RA-OS installation. - -**Note:** RA-OS must be running (`npm run dev`) for the MCP server to work. - ---- - -## HTTP Transport - -For assistants that support HTTP transport: - -**URL:** `http://127.0.0.1:44145/mcp` - -```json -{ - "mcpServers": { - "ra-h": { - "url": "http://127.0.0.1:44145/mcp" - } - } -} -``` - -To start the HTTP server standalone: -```bash -node apps/mcp-server/server.js -``` --- @@ -85,11 +99,20 @@ Once connected, you can ask your AI assistant: "Add this conversation summary to RA-H as a new node" "Find all nodes with the 'research' dimension" "Create an edge between node 123 and node 456" -"What are my most connected nodes?" ``` --- +## Key Files + +| File | Purpose | +|------|---------| +| `apps/mcp-server-standalone/` | **Standalone server (direct SQLite, recommended)** | +| `apps/mcp-server/server.js` | HTTP MCP server | +| `apps/mcp-server/stdio-server.js` | STDIO bridge to HTTP server | + +--- + ## Security - The MCP server only binds to `127.0.0.1` — localhost only @@ -98,38 +121,22 @@ Once connected, you can ask your AI assistant: --- -## Health Check - -```bash -curl http://127.0.0.1:44145/status -``` - ---- - -## Key Files - -| File | Purpose | -|------|---------| -| `apps/mcp-server/server.js` | HTTP MCP server | -| `apps/mcp-server/stdio-server.js` | STDIO MCP server (for Claude Code) | - ---- - ## Troubleshooting -### "Connection refused" +### "Database not found" + +Run RA-OS at least once to create the database: +```bash +npm run dev +``` + +### "Tools not showing" (npm package) + +1. Make sure Node.js 18+ is installed: `node --version` +2. Try running manually: `npx ra-h-mcp-server` +3. Restart Claude Code + +### "Connection refused" (HTTP method) 1. Make sure RA-OS is running: `npm run dev` -2. Check the port isn't blocked: `lsof -i :44145` -3. Verify the server started: check terminal output - -### "Tools not showing" - -1. Restart your AI assistant after configuring -2. Verify the path in your config is correct -3. Check `node apps/mcp-server/stdio-server.js` runs without errors - -### "Permission denied" - -1. Make sure the stdio-server.js file is readable -2. Check Node.js is in your PATH +2. Check the port: `lsof -i :44145`