Files
ra-h-os/apps/mcp-server-standalone/index.js
T
“BeeRad”andClaude Sonnet 4.5 e6c8c21f1f fix: tighten MCP instructions and tool description constraints
Clarifies get_context vs start-here hierarchy ("also" keyword).
Adds batch/result limits to rah_get_nodes, rah_search_nodes,
rah_query_edges descriptions. Adds title length to rah_add_node.

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

608 lines
20 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 for a quick orientation (stats, hubs, dimensions).',
'For simple tasks (add a node, search), the tool descriptions have everything you need — just execute.',
'For complex or ambiguous tasks, also call rah_read_guide("start-here") for full graph understanding.',
'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. Title: max 160 chars, clear and descriptive. 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. Returns up to 25 results (default 10). 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 (max 10 per call).',
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 up to 50 edges (default 25) with 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);
});