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:
co-authored by
Claude Opus 4.5
parent
5c480b01d6
commit
bcea76800b
@@ -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
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user