feat: standalone MCP server (ra-h-mcp-server)

- Added apps/mcp-server-standalone/ with direct SQLite access
- Works without RA-OS app running
- Install via: npx ra-h-mcp-server
- Updated docs/8_mcp.md with setup instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-02 09:26:46 +11:00
co-authored by Claude Opus 4.5
parent 5c480b01d6
commit bcea76800b
8 changed files with 1426 additions and 84 deletions
+74
View File
@@ -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
```
+437
View File
@@ -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);
});
+48
View File
@@ -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 <bradwmorriswork@gmail.com>",
"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"
]
}
@@ -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
};
@@ -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
};
@@ -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
};
@@ -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
};
+91 -84
View File
@@ -1,16 +1,75 @@
# MCP Server # 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` 1. Start RA-OS: `npm run dev`
2. Configure your AI assistant (see below) 2. Configure:
3. Use naturally: "Search RA-H for my notes on X" or "Add this to RA-H"
```json
{
"mcpServers": {
"ra-h": {
"url": "http://127.0.0.1:44145/mcp"
}
}
}
```
--- ---
@@ -24,55 +83,10 @@
| `rah_get_nodes` | Get nodes by ID | | `rah_get_nodes` | Get nodes by ID |
| `rah_create_edge` | Create relationship between nodes | | `rah_create_edge` | Create relationship between nodes |
| `rah_query_edges` | Query existing edges | | `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_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_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" "Add this conversation summary to RA-H as a new node"
"Find all nodes with the 'research' dimension" "Find all nodes with the 'research' dimension"
"Create an edge between node 123 and node 456" "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 ## Security
- The MCP server only binds to `127.0.0.1` — localhost only - 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 ## 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` 1. Make sure RA-OS is running: `npm run dev`
2. Check the port isn't blocked: `lsof -i :44145` 2. Check the port: `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