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
> 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`