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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user