Files
ra-h-os/apps/mcp-server-standalone/index.js
T
“BeeRad”andClaude Sonnet 4.5 ed82a85fa9 feat: add start-here master guide + enriched tool descriptions
Adds immutable "start-here" system guide as the master orientation doc
for agents. Enriches all tool descriptions with key rules at point of
use so agents can execute simple tasks without reading guides.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 11:30:53 +11:00

608 lines
19 KiB
JavaScript
Executable File

#!/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');
const guideService = require('./services/guideService');
// Server info
const serverInfo = {
name: 'ra-h-standalone',
version: '1.2.0'
};
const instructions = [
"RA-H is the user's personal knowledge graph — local SQLite, fully on-device.",
'Call rah_get_context first to orient yourself.',
'For simple tasks (add a node, search), the tool descriptions have everything you need — just execute.',
'For complex or ambiguous tasks, call rah_read_guide("start-here") first for full orientation.',
'Proactively identify valuable information in conversations and offer to save it.',
'Search before creating to avoid duplicates.',
'All data stays on this device.'
].join(' ');
// Tool schemas
const addNodeInputSchema = {
title: z.string().min(1).max(160).describe('Clear, descriptive 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('One-sentence summary. Helps search and AI understanding.'),
dimensions: z.array(z.string()).min(1).max(5).describe('1-5 categories. Call rah_list_dimensions first to use existing ones.'),
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("The 'subject' node (reads: source [explanation] target)"),
targetId: z.number().int().positive().describe('Target node ID'),
explanation: z.string().min(1).describe("Human-readable explanation. Should read as a sentence: 'Alice invented this technique'")
};
const updateEdgeInputSchema = {
id: z.number().int().positive().describe('Edge ID'),
explanation: z.string().min(1).describe('Updated explanation for this connection')
};
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')
};
const readGuideInputSchema = {
name: z.string().min(1).describe('Guide name (e.g. "edges", "creating-nodes", "schema")')
};
const writeGuideInputSchema = {
name: z.string().min(1).describe('Guide name (lowercase, no spaces)'),
content: z.string().min(1).describe('Full markdown content including YAML frontmatter (name, description)')
};
const deleteGuideInputSchema = {
name: z.string().min(1).describe('Guide 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 });
// ========== CONTEXT TOOL ==========
server.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get knowledge graph overview: stats, hub nodes (most connected), dimensions, recent activity, and available guides. Call this first to orient yourself. For deeper understanding, follow up with rah_read_guide("start-here").',
inputSchema: {}
},
async () => {
const context = nodeService.getContext();
const guides = guideService.listGuides();
context.guides = guides.map(g => ({ name: g.name, description: g.description, immutable: g.immutable }));
// First-run welcome message
if (context.stats.nodeCount === 0) {
return {
content: [{ type: 'text', text: 'Empty knowledge graph. This is a fresh start! Suggest adding the first node about something the user is working on or interested in.' }],
structuredContent: {
...context,
welcome: true,
suggestion: 'Ask the user what they\'re working on or interested in, then create the first node.'
}
};
}
const summary = `Graph: ${context.stats.nodeCount} nodes, ${context.stats.edgeCount} edges, ${context.stats.dimensionCount} dimensions, ${guides.length} guides.`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: context
};
}
);
// ========== NODE TOOLS ==========
server.registerTool(
'rah_add_node',
{
title: 'Add RA-H node',
description: 'Create a new node. Always search first (rah_search_nodes) to avoid duplicates. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "content" = your notes/analysis. "chunk" = verbatim source text. "description" = one-sentence summary for search. Assign 1-5 dimensions — call rah_list_dimensions first to use existing ones.',
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: 'Search nodes by keyword across title, description, and content fields. Prioritizes exact title matches. Call before creating nodes to check for duplicates. Optionally filter by dimensions.',
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 to existing content (not replaced). Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten.',
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: 'Connect two nodes with an edge. Edges are the most valuable part of the graph — they represent understanding, not proximity. Direction matters: reads as sourceId → [explanation] → targetId. The explanation should read as a sentence (e.g. "invented this technique", "contradicts the claim in").',
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_update_edge',
{
title: 'Update RA-H edge',
description: 'Update an edge explanation. Use when a connection needs a better or corrected explanation.',
inputSchema: updateEdgeInputSchema
},
async ({ id, explanation }) => {
const edge = edgeService.updateEdge(id, { explanation: explanation.trim() });
return {
content: [{ type: 'text', text: `Updated edge #${id}` }],
structuredContent: {
success: true,
edgeId: edge.id,
message: `Updated edge #${id}`
}
};
}
);
server.registerTool(
'rah_query_edges',
{
title: 'Query RA-H edges',
description: 'Find edges/connections. Optionally filter by nodeId to see all connections for a specific node. Returns edge IDs, connected node IDs, and explanations.',
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. Use lowercase, singular form (e.g. "biology" not "Biology" or "biologies"). Set isPriority=true to lock it for automatic assignment to new nodes. Always include a description.',
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}`
}
};
}
);
// ========== GUIDE TOOLS ==========
server.registerTool(
'rah_list_guides',
{
title: 'List RA-H guides',
description: 'List all guides — system guides (immutable reference docs) and user-created guides (custom workflows and preferences). Read "start-here" first for orientation.',
inputSchema: {}
},
async () => {
const guides = guideService.listGuides();
return {
content: [{ type: 'text', text: `Found ${guides.length} guide(s).` }],
structuredContent: {
count: guides.length,
guides
}
};
}
);
server.registerTool(
'rah_read_guide',
{
title: 'Read RA-H guide',
description: 'Read a guide by name. Returns full markdown with procedural instructions. Read "start-here" for master orientation. Call rah_list_guides to see all available guides.',
inputSchema: readGuideInputSchema
},
async ({ name }) => {
const guide = guideService.readGuide(name);
if (!guide) {
throw new Error(`Guide "${name}" not found. Call rah_list_guides to see available guides.`);
}
return {
content: [{ type: 'text', text: guide.content }],
structuredContent: guide
};
}
);
server.registerTool(
'rah_write_guide',
{
title: 'Write RA-H guide',
description: 'Create or update a custom guide. System guides cannot be modified. Content should be markdown with YAML frontmatter (name, description).',
inputSchema: writeGuideInputSchema
},
async ({ name, content }) => {
const result = guideService.writeGuide(name, content);
if (!result.success) {
throw new Error(result.error);
}
return {
content: [{ type: 'text', text: `Guide "${name}" saved.` }],
structuredContent: {
success: true,
name,
message: `Guide "${name}" saved.`
}
};
}
);
server.registerTool(
'rah_delete_guide',
{
title: 'Delete RA-H guide',
description: 'Delete a custom guide. System guides cannot be deleted.',
inputSchema: deleteGuideInputSchema
},
async ({ name }) => {
const result = guideService.deleteGuide(name);
if (!result.success) {
throw new Error(result.error);
}
return {
content: [{ type: 'text', text: `Guide "${name}" deleted.` }],
structuredContent: {
success: true,
name,
message: `Guide "${name}" deleted.`
}
};
}
);
// 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);
});