From 7c6197fc0d22c1a4227a6cd2a66c4537015a359c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Mon, 16 Feb 2026 09:16:51 +1100 Subject: [PATCH] =?UTF-8?q?fix:=20MCP=20servers=20=E2=80=94=20broken=20INS?= =?UTF-8?q?ERT=20into=20dropped=20`type`=20column,=20stale=20`content`=20r?= =?UTF-8?q?efs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous schema sync (86f43c9) was incomplete — it renamed variables but left the SQL statements writing to `type` and `content` columns that no longer exist in the schema. Fresh installs crash on first node create. nodeService.js: - Remove `type` from INSERT/UPDATE, replace with `event_date` - Fix `content` → `notes` in INSERT values and UPDATE SET clauses - Add `sanitizeTitle()` and chunk fallback logic sqlite-client.js: - Remove `is_pinned` from CREATE TABLE - Add `description` and `icon` to dimensions table index.js: - Add temporal filter fields to search schema - Improved description prompts - Dynamic date injection in instructions server.js + stdio-server.js: - Add content→notes mapping in update handlers - Fix output returning `notes` instead of `content` - Add extraction tool schemas and handlers Co-Authored-By: Claude Opus 4.6 --- apps/mcp-server-standalone/index.js | 89 +++++-- .../services/nodeService.js | 57 +++-- .../services/sqlite-client.js | 6 +- apps/mcp-server/server.js | 11 +- apps/mcp-server/stdio-server.js | 224 +++++++++++++++++- 5 files changed, 335 insertions(+), 52 deletions(-) diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index 48efa5e..4a93be9 100755 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -40,25 +40,31 @@ const serverInfo = { version: '1.4.1' }; -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.', - 'Knowledge capture: after substantive exchanges, offer to save valuable information.', - 'Triggers: a new insight emerges, a decision is made, a person/entity/concept is discussed in depth, research or references are shared, a connection to existing knowledge surfaces.', - 'When offering, propose a specific node — title, dimensions, one-line description — so the user can approve with minimal friction.', - 'Don\'t ask "should I save this?" — instead say "I\'d add this as: [title] in [dimensions] — want me to?"', - 'Search before creating to avoid duplicates.', - 'All data stays on this device.' -].join(' '); +function buildInstructions() { + const now = new Date().toISOString().split('T')[0]; + return [ + `Today's date: ${now}.`, + "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.', + 'Knowledge capture: after substantive exchanges, offer to save valuable information.', + 'Triggers: a new insight emerges, a decision is made, a person/entity/concept is discussed in depth, research or references are shared, a connection to existing knowledge surfaces.', + 'When offering, propose a specific node — title, dimensions, and a concrete description (WHAT it is + WHY it matters, no vague "discusses/explores") — so the user can approve with minimal friction.', + 'Don\'t ask "should I save this?" — instead say "I\'d add this as: [title] in [dimensions] — want me to?"', + 'Search before creating to avoid duplicates.', + 'All data stays on this device.' + ].join(' '); +} + +const instructions = buildInstructions(); // 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.'), + description: z.string().max(2000).optional().describe('One-sentence summary: WHAT this is (explicit, concrete) + WHY it matters. No weak verbs (discusses, explores, examines). Example: "Podcast — Lex Fridman interviews Sam Altman on AGI timelines. First public comments since board drama."'), 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') @@ -67,7 +73,11 @@ const addNodeInputSchema = { 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') + dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions'), + created_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created on or after this date.'), + created_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes created before this date.'), + event_after: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date on or after this date.'), + event_before: z.string().optional().describe('ISO date (YYYY-MM-DD). Only return nodes with event_date before this date.') }; const getNodesInputSchema = { @@ -300,6 +310,7 @@ async function main() { { 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.', + // Note: MCP schema uses "content" for external API compat; mapped to "notes" internally inputSchema: addNodeInputSchema }, async ({ title, content, link, description, dimensions, metadata, chunk }) => { @@ -337,14 +348,24 @@ async function main() { { title: 'Search RA-H nodes', description: 'Search nodes by keyword across title, description, and content fields. Multi-word queries find nodes containing all words (not exact phrases). Returns up to 25 results (default 10). Call before creating nodes to check for duplicates. Optionally filter by dimensions.', + // Note: searches across title, description, and notes columns inputSchema: searchNodesInputSchema }, - async ({ query: searchQuery, limit = 10, dimensions }) => { + async ({ query: searchQuery, limit = 10, dimensions, created_after, created_before, event_after, event_before }) => { const normalizedDimensions = sanitizeDimensions(dimensions || []); const safeLimit = Math.min(Math.max(limit, 1), 25); const trimmedQuery = searchQuery.trim(); const fts = checkFtsAvailability(); + // Build temporal filter clauses + const temporalClauses = []; + const temporalParams = []; + if (created_after) { temporalClauses.push('n.created_at >= ?'); temporalParams.push(created_after); } + if (created_before) { temporalClauses.push('n.created_at < ?'); temporalParams.push(created_before); } + if (event_after) { temporalClauses.push('n.event_date >= ?'); temporalParams.push(event_after); } + if (event_before) { temporalClauses.push('n.event_date < ?'); temporalParams.push(event_before); } + const temporalSQL = temporalClauses.length > 0 ? temporalClauses.map(c => `AND ${c}`).join(' ') : ''; + let nodes = null; // Try FTS5 first (handles multi-word queries naturally) @@ -359,7 +380,8 @@ async function main() { WITH fts_matches AS ( SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100 ) - SELECT n.id, n.title, n.description, n.notes, n.link, n.updated_at, + SELECT n.id, n.title, n.description, n.notes, n.link, + n.created_at, n.updated_at, n.event_date, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM fts_matches fm @@ -369,23 +391,26 @@ async function main() { WHERE nd.node_id = n.id AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')}) ) + ${temporalSQL} ORDER BY fm.rank LIMIT ? `; - params = [ftsQuery, ...normalizedDimensions, safeLimit]; + params = [ftsQuery, ...normalizedDimensions, ...temporalParams, safeLimit]; } else { sql = ` WITH fts_matches AS ( SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ? ) - SELECT n.id, n.title, n.description, n.notes, n.link, n.updated_at, + SELECT n.id, n.title, n.description, n.notes, n.link, + n.created_at, n.updated_at, n.event_date, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM fts_matches fm JOIN nodes n ON n.id = fm.rowid + ${temporalSQL ? 'WHERE ' + temporalClauses.join(' AND ') : ''} ORDER BY fm.rank `; - params = [ftsQuery, safeLimit]; + params = [ftsQuery, safeLimit, ...temporalParams]; } const rows = query(sql, params); @@ -396,7 +421,9 @@ async function main() { description: row.description ?? null, link: row.link ?? null, dimensions: JSON.parse(row.dimensions_json || '[]'), - updated_at: row.updated_at + created_at: row.created_at, + updated_at: row.updated_at, + event_date: row.event_date ?? null })); } catch (err) { log('FTS search failed, falling back to LIKE:', err.message); @@ -410,7 +437,8 @@ async function main() { const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0); let sql = ` - SELECT n.id, n.title, n.description, n.notes, n.link, n.updated_at, + SELECT n.id, n.title, n.description, n.notes, n.link, + n.created_at, n.updated_at, n.event_date, COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension) FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json FROM nodes n @@ -432,6 +460,12 @@ async function main() { params.push(...normalizedDimensions); } + // Temporal filters + if (temporalSQL) { + sql += ` ${temporalSQL}`; + params.push(...temporalParams); + } + sql += ` ORDER BY n.updated_at DESC LIMIT ?`; params.push(safeLimit); @@ -443,7 +477,9 @@ async function main() { description: row.description ?? null, link: row.link ?? null, dimensions: JSON.parse(row.dimensions_json || '[]'), - updated_at: row.updated_at + created_at: row.created_at, + updated_at: row.updated_at, + event_date: row.event_date ?? null })); } @@ -493,7 +529,9 @@ async function main() { chunk_length: rawChunk ? rawChunk.length : 0, dimensions: node.dimensions || [], metadata: node.metadata ?? null, - updated_at: node.updated_at + created_at: node.created_at, + updated_at: node.updated_at, + event_date: node.event_date ?? null }); } } @@ -512,7 +550,7 @@ async function main() { '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.', + description: 'Update an existing node. Content is APPENDED to existing notes (not replaced). Dimensions are REPLACED entirely with the new array. Title, description, and link are overwritten.', inputSchema: updateNodeInputSchema }, async ({ id, updates }) => { @@ -520,12 +558,13 @@ async function main() { throw new Error('At least one field must be provided in updates.'); } - // Map external 'content' param to internal 'notes' + // Map MCP 'content' field → internal 'notes' field const mappedUpdates = { ...updates }; if (mappedUpdates.content !== undefined) { mappedUpdates.notes = mappedUpdates.content; delete mappedUpdates.content; } + const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: true }); return { diff --git a/apps/mcp-server-standalone/services/nodeService.js b/apps/mcp-server-standalone/services/nodeService.js index 7cca795..1fe298d 100644 --- a/apps/mcp-server-standalone/services/nodeService.js +++ b/apps/mcp-server-standalone/services/nodeService.js @@ -90,38 +90,61 @@ function getNodeById(id) { }; } +/** + * Sanitize title — strip extraction artifacts. + */ +function sanitizeTitle(title) { + let clean = title.trim(); + if (clean.startsWith('Title: ')) clean = clean.slice(7); + if (clean.endsWith(' / X')) clean = clean.slice(0, -4); + clean = clean.replace(/\s+/g, ' '); + return clean.slice(0, 160); +} + /** * Create a new node. */ function createNode(nodeData) { const { - title, + title: rawTitle, description, notes, link, - type, + event_date, dimensions = [], chunk, metadata = {} } = nodeData; + const title = sanitizeTitle(rawTitle); + const now = new Date().toISOString(); const db = getDb(); + // Build chunk fallback from available fields when no explicit chunk provided + let chunkToStore = chunk ?? null; + if (!chunkToStore || !chunkToStore.trim()) { + const fallbackParts = [title, description, notes].filter(Boolean); + const fallback = fallbackParts.join('\n\n').trim(); + if (fallback) { + chunkToStore = fallback; + } + } + const nodeId = transaction(() => { const stmt = db.prepare(` - INSERT INTO nodes (title, description, notes, link, type, metadata, chunk, created_at, updated_at) + INSERT INTO nodes (title, description, notes, link, event_date, metadata, chunk, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( title, description ?? null, - content ?? null, + notes ?? null, link ?? null, - type ?? null, + event_date ?? null, JSON.stringify(metadata), - chunk ?? null, + chunkToStore, now, now ); @@ -146,11 +169,11 @@ function createNode(nodeData) { /** * Update an existing node. - * Note: content is APPENDED by default (MCP tool behavior), not replaced. + * Note: notes is APPENDED by default (MCP tool behavior), not replaced. */ function updateNode(id, updates, options = {}) { const { appendNotes = true } = options; - const { title, description, notes, link, type, dimensions, chunk, metadata } = updates; + const { title, description, notes, link, event_date, dimensions, chunk, metadata } = updates; const now = new Date().toISOString(); const db = getDb(); @@ -172,23 +195,23 @@ function updateNode(id, updates, options = {}) { setFields.push('description = ?'); params.push(description); } - if (content !== undefined) { + if (notes !== undefined) { if (appendNotes && existing.notes) { - // Append to existing content - setFields.push('content = ?'); - params.push(existing.notes + '\n\n' + content); + // Append to existing notes + setFields.push('notes = ?'); + params.push(existing.notes + '\n\n' + notes); } else { - setFields.push('content = ?'); - params.push(content); + setFields.push('notes = ?'); + params.push(notes); } } if (link !== undefined) { setFields.push('link = ?'); params.push(link); } - if (type !== undefined) { - setFields.push('type = ?'); - params.push(type); + if (event_date !== undefined) { + setFields.push('event_date = ?'); + params.push(event_date); } if (chunk !== undefined) { setFields.push('chunk = ?'); diff --git a/apps/mcp-server-standalone/services/sqlite-client.js b/apps/mcp-server-standalone/services/sqlite-client.js index ed17dab..9a1ffb3 100644 --- a/apps/mcp-server-standalone/services/sqlite-client.js +++ b/apps/mcp-server-standalone/services/sqlite-client.js @@ -60,8 +60,7 @@ function initDatabase() { embedding BLOB, embedding_updated_at TEXT, embedding_text TEXT, - chunk_status TEXT DEFAULT 'not_chunked', - is_pinned INTEGER DEFAULT 0 + chunk_status TEXT DEFAULT 'not_chunked' ); CREATE TABLE IF NOT EXISTS edges ( @@ -71,7 +70,6 @@ function initDatabase() { source TEXT, created_at TEXT, context TEXT, - FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE ); @@ -89,6 +87,8 @@ function initDatabase() { CREATE TABLE IF NOT EXISTS dimensions ( name TEXT PRIMARY KEY, + description TEXT, + icon TEXT, is_priority INTEGER DEFAULT 0, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index d90f303..a2d8f69 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -446,9 +446,16 @@ mcpServer.registerTool( throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.'); } + // Map MCP 'content' field → internal 'notes' field + const mappedUpdates = { ...updates }; + if (mappedUpdates.content !== undefined) { + mappedUpdates.notes = mappedUpdates.content; + delete mappedUpdates.content; + } + const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', - body: JSON.stringify(updates) + body: JSON.stringify(mappedUpdates) }); const node = result.node || result.data; @@ -485,7 +492,7 @@ mcpServer.registerTool( nodes.push({ id: result.node.id, title: result.node.title, - content: result.node.content ?? null, + notes: result.node.notes ?? null, link: result.node.link ?? null, dimensions: result.node.dimensions || [], updated_at: result.node.updated_at diff --git a/apps/mcp-server/stdio-server.js b/apps/mcp-server/stdio-server.js index cff62ad..1a71642 100644 --- a/apps/mcp-server/stdio-server.js +++ b/apps/mcp-server/stdio-server.js @@ -10,9 +10,12 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio const packageJson = require('../../package.json'); const instructions = [ - 'Use rah.add_node to summarize conversations or files into nodes with dimensions.', - 'Use rah.search_nodes to recall prior notes before you suggest creating new ones.', - 'All operations happen locally on this device; data never leaves 127.0.0.1.' + 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', + 'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).', + 'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.', + 'Search before creating: use rah_search_nodes to check if content already exists.', + 'Every edge needs an explanation: why does this connection exist?', + 'All data stays local on this device; nothing leaves 127.0.0.1.', ].join(' '); const serverInfo = { @@ -202,6 +205,45 @@ const searchEmbeddingsOutputSchema = { ) }; +// rah_extract_url schemas +const extractUrlInputSchema = { + url: z.string().url().describe('URL of the webpage to extract content from') +}; + +const extractUrlOutputSchema = { + success: z.boolean(), + title: z.string(), + content: z.string(), + chunk: z.string(), + metadata: z.record(z.any()) +}; + +// rah_extract_youtube schemas +const extractYoutubeInputSchema = { + url: z.string().describe('YouTube video URL to extract transcript from') +}; + +const extractYoutubeOutputSchema = { + success: z.boolean(), + title: z.string(), + channel: z.string(), + transcript: z.string(), + metadata: z.record(z.any()) +}; + +// rah_extract_pdf schemas +const extractPdfInputSchema = { + url: z.string().url().describe('URL of the PDF file to extract content from') +}; + +const extractPdfOutputSchema = { + success: z.boolean(), + title: z.string(), + content: z.string(), + chunk: z.string(), + metadata: z.record(z.any()) +}; + const server = new McpServer(serverInfo, { instructions }); function logError(...args) { @@ -375,9 +417,16 @@ server.registerTool( throw new Error('At least one field must be provided in updates.'); } + // Map MCP 'content' field → internal 'notes' field + const mappedUpdates = { ...updates }; + if (mappedUpdates.content !== undefined) { + mappedUpdates.notes = mappedUpdates.content; + delete mappedUpdates.content; + } + const result = await callRaHApi(`/api/nodes/${id}`, { method: 'PUT', - body: JSON.stringify(updates) + body: JSON.stringify(mappedUpdates) }); const node = result.node || result.data; @@ -414,7 +463,7 @@ server.registerTool( nodes.push({ id: result.node.id, title: result.node.title, - content: result.node.content ?? null, + notes: result.node.notes ?? null, link: result.node.link ?? null, dimensions: result.node.dimensions || [], updated_at: result.node.updated_at @@ -655,6 +704,171 @@ server.registerTool( } ); +server.registerTool( + 'rah_extract_url', + { + title: 'Extract URL content', + description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.', + inputSchema: extractUrlInputSchema, + outputSchema: extractUrlOutputSchema + }, + async ({ url }) => { + const result = await callRaHApi('/api/extract/url', { + method: 'POST', + body: JSON.stringify({ url }) + }); + + const summary = `Extracted content from: ${result.title || 'webpage'}`; + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + success: true, + title: result.title || 'Untitled', + content: result.content || '', + chunk: result.chunk || '', + metadata: result.metadata || {} + } + }; + } +); + +server.registerTool( + 'rah_extract_youtube', + { + title: 'Extract YouTube transcript', + description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.', + inputSchema: extractYoutubeInputSchema, + outputSchema: extractYoutubeOutputSchema + }, + async ({ url }) => { + const result = await callRaHApi('/api/extract/youtube', { + method: 'POST', + body: JSON.stringify({ url }) + }); + + const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`; + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + success: true, + title: result.title || 'Untitled', + channel: result.channel || 'Unknown', + transcript: result.transcript || '', + metadata: result.metadata || {} + } + }; + } +); + +server.registerTool( + 'rah_extract_pdf', + { + title: 'Extract PDF content', + description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.', + inputSchema: extractPdfInputSchema, + outputSchema: extractPdfOutputSchema + }, + async ({ url }) => { + const result = await callRaHApi('/api/extract/pdf', { + method: 'POST', + body: JSON.stringify({ url }) + }); + + const summary = `Extracted content from: ${result.title || 'PDF document'}`; + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + success: true, + title: result.title || 'Untitled PDF', + content: result.content || '', + chunk: result.chunk || '', + metadata: result.metadata || {} + } + }; + } +); + +// rah_get_context — orientation tool for external agents +const getContextOutputSchema = { + schema: z.object({ + nodeCount: z.number(), + edgeCount: z.number(), + dimensionCount: z.number() + }), + hubNodes: z.array(z.object({ + id: z.number(), + title: z.string(), + description: z.string().nullable(), + edgeCount: z.number() + })), + dimensions: z.array(z.object({ + name: z.string(), + nodeCount: z.number(), + description: z.string().nullable() + })), + guides: z.array(z.string()) +}; + +server.registerTool( + 'rah_get_context', + { + title: 'Get RA-H context', + description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.', + inputSchema: {}, + outputSchema: getContextOutputSchema + }, + async () => { + // Fetch hub nodes (top 5 most-connected) + const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=5', { method: 'GET' }); + const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({ + id: n.id, + title: n.title, + description: n.description ?? null, + edgeCount: n.edge_count ?? 0 + })) : []; + + // Fetch dimensions + const dimResult = await callRaHApi('/api/dimensions', { method: 'GET' }); + const dimensions = Array.isArray(dimResult.data) ? dimResult.data.map(d => ({ + name: d.name, + nodeCount: d.node_count ?? 0, + description: d.description ?? null + })) : []; + + // Fetch guides + const guideResult = await callRaHApi('/api/guides', { method: 'GET' }); + const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : []; + + // Get counts + const nodeCount = hubNodes.length > 0 ? undefined : 0; + const stats = { + nodeCount: nodeCount ?? hubNodes.reduce((_, n) => 0, 0), + edgeCount: 0, + dimensionCount: dimensions.length + }; + + // Try to get actual counts from a stats endpoint or compute + try { + const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' }); + if (countResult.total !== undefined) { + stats.nodeCount = countResult.total; + } + } catch { /* use defaults */ } + + const summary = `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.`; + + return { + content: [{ type: 'text', text: summary }], + structuredContent: { + schema: stats, + hubNodes, + dimensions, + guides + } + }; + } +); + async function main() { const transport = new StdioServerTransport(); await server.connect(transport);