fix: MCP servers — broken INSERT into dropped type column, stale content refs
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2f6518207d
commit
7c6197fc0d
@@ -40,25 +40,31 @@ const serverInfo = {
|
|||||||
version: '1.4.1'
|
version: '1.4.1'
|
||||||
};
|
};
|
||||||
|
|
||||||
const instructions = [
|
function buildInstructions() {
|
||||||
"RA-H is the user's personal knowledge graph — local SQLite, fully on-device.",
|
const now = new Date().toISOString().split('T')[0];
|
||||||
'Call rah_get_context first for a quick orientation (stats, hubs, dimensions).',
|
return [
|
||||||
'For simple tasks (add a node, search), the tool descriptions have everything you need — just execute.',
|
`Today's date: ${now}.`,
|
||||||
'For complex or ambiguous tasks, also call rah_read_guide("start-here") for full graph understanding.',
|
"RA-H is the user's personal knowledge graph — local SQLite, fully on-device.",
|
||||||
'Knowledge capture: after substantive exchanges, offer to save valuable information.',
|
'Call rah_get_context first for a quick orientation (stats, hubs, dimensions).',
|
||||||
'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.',
|
'For simple tasks (add a node, search), the tool descriptions have everything you need — just execute.',
|
||||||
'When offering, propose a specific node — title, dimensions, one-line description — so the user can approve with minimal friction.',
|
'For complex or ambiguous tasks, also call rah_read_guide("start-here") for full graph understanding.',
|
||||||
'Don\'t ask "should I save this?" — instead say "I\'d add this as: [title] in [dimensions] — want me to?"',
|
'Knowledge capture: after substantive exchanges, offer to save valuable information.',
|
||||||
'Search before creating to avoid duplicates.',
|
'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.',
|
||||||
'All data stays on this device.'
|
'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.',
|
||||||
].join(' ');
|
'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
|
// Tool schemas
|
||||||
const addNodeInputSchema = {
|
const addNodeInputSchema = {
|
||||||
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
|
title: z.string().min(1).max(160).describe('Clear, descriptive title'),
|
||||||
content: z.string().max(20000).optional().describe('Node content/notes'),
|
content: z.string().max(20000).optional().describe('Node content/notes'),
|
||||||
link: z.string().url().optional().describe('Source URL'),
|
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.'),
|
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'),
|
metadata: z.record(z.any()).optional().describe('Additional metadata'),
|
||||||
chunk: z.string().max(50000).optional().describe('Full source text')
|
chunk: z.string().max(50000).optional().describe('Full source text')
|
||||||
@@ -67,7 +73,11 @@ const addNodeInputSchema = {
|
|||||||
const searchNodesInputSchema = {
|
const searchNodesInputSchema = {
|
||||||
query: z.string().min(1).max(400).describe('Search query'),
|
query: z.string().min(1).max(400).describe('Search query'),
|
||||||
limit: z.number().min(1).max(25).optional().describe('Max results (default 10)'),
|
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 = {
|
const getNodesInputSchema = {
|
||||||
@@ -300,6 +310,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
title: 'Add RA-H 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.',
|
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
|
inputSchema: addNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
|
async ({ title, content, link, description, dimensions, metadata, chunk }) => {
|
||||||
@@ -337,14 +348,24 @@ async function main() {
|
|||||||
{
|
{
|
||||||
title: 'Search RA-H nodes',
|
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.',
|
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
|
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 normalizedDimensions = sanitizeDimensions(dimensions || []);
|
||||||
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
||||||
const trimmedQuery = searchQuery.trim();
|
const trimmedQuery = searchQuery.trim();
|
||||||
const fts = checkFtsAvailability();
|
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;
|
let nodes = null;
|
||||||
|
|
||||||
// Try FTS5 first (handles multi-word queries naturally)
|
// Try FTS5 first (handles multi-word queries naturally)
|
||||||
@@ -359,7 +380,8 @@ async function main() {
|
|||||||
WITH fts_matches AS (
|
WITH fts_matches AS (
|
||||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT 100
|
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)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
FROM fts_matches fm
|
FROM fts_matches fm
|
||||||
@@ -369,23 +391,26 @@ async function main() {
|
|||||||
WHERE nd.node_id = n.id
|
WHERE nd.node_id = n.id
|
||||||
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
|
AND nd.dimension IN (${normalizedDimensions.map(() => '?').join(',')})
|
||||||
)
|
)
|
||||||
|
${temporalSQL}
|
||||||
ORDER BY fm.rank
|
ORDER BY fm.rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`;
|
`;
|
||||||
params = [ftsQuery, ...normalizedDimensions, safeLimit];
|
params = [ftsQuery, ...normalizedDimensions, ...temporalParams, safeLimit];
|
||||||
} else {
|
} else {
|
||||||
sql = `
|
sql = `
|
||||||
WITH fts_matches AS (
|
WITH fts_matches AS (
|
||||||
SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? LIMIT ?
|
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)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
FROM fts_matches fm
|
FROM fts_matches fm
|
||||||
JOIN nodes n ON n.id = fm.rowid
|
JOIN nodes n ON n.id = fm.rowid
|
||||||
|
${temporalSQL ? 'WHERE ' + temporalClauses.join(' AND ') : ''}
|
||||||
ORDER BY fm.rank
|
ORDER BY fm.rank
|
||||||
`;
|
`;
|
||||||
params = [ftsQuery, safeLimit];
|
params = [ftsQuery, safeLimit, ...temporalParams];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = query(sql, params);
|
const rows = query(sql, params);
|
||||||
@@ -396,7 +421,9 @@ async function main() {
|
|||||||
description: row.description ?? null,
|
description: row.description ?? null,
|
||||||
link: row.link ?? null,
|
link: row.link ?? null,
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
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) {
|
} catch (err) {
|
||||||
log('FTS search failed, falling back to LIKE:', err.message);
|
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);
|
const words = trimmedQuery.split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
let sql = `
|
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)
|
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json
|
||||||
FROM nodes n
|
FROM nodes n
|
||||||
@@ -432,6 +460,12 @@ async function main() {
|
|||||||
params.push(...normalizedDimensions);
|
params.push(...normalizedDimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporal filters
|
||||||
|
if (temporalSQL) {
|
||||||
|
sql += ` ${temporalSQL}`;
|
||||||
|
params.push(...temporalParams);
|
||||||
|
}
|
||||||
|
|
||||||
sql += ` ORDER BY n.updated_at DESC LIMIT ?`;
|
sql += ` ORDER BY n.updated_at DESC LIMIT ?`;
|
||||||
params.push(safeLimit);
|
params.push(safeLimit);
|
||||||
|
|
||||||
@@ -443,7 +477,9 @@ async function main() {
|
|||||||
description: row.description ?? null,
|
description: row.description ?? null,
|
||||||
link: row.link ?? null,
|
link: row.link ?? null,
|
||||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
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,
|
chunk_length: rawChunk ? rawChunk.length : 0,
|
||||||
dimensions: node.dimensions || [],
|
dimensions: node.dimensions || [],
|
||||||
metadata: node.metadata ?? null,
|
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',
|
'rah_update_node',
|
||||||
{
|
{
|
||||||
title: 'Update RA-H 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
|
inputSchema: updateNodeInputSchema
|
||||||
},
|
},
|
||||||
async ({ id, updates }) => {
|
async ({ id, updates }) => {
|
||||||
@@ -520,12 +558,13 @@ async function main() {
|
|||||||
throw new Error('At least one field must be provided in updates.');
|
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 };
|
const mappedUpdates = { ...updates };
|
||||||
if (mappedUpdates.content !== undefined) {
|
if (mappedUpdates.content !== undefined) {
|
||||||
mappedUpdates.notes = mappedUpdates.content;
|
mappedUpdates.notes = mappedUpdates.content;
|
||||||
delete mappedUpdates.content;
|
delete mappedUpdates.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: true });
|
const node = nodeService.updateNode(id, mappedUpdates, { appendNotes: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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.
|
* Create a new node.
|
||||||
*/
|
*/
|
||||||
function createNode(nodeData) {
|
function createNode(nodeData) {
|
||||||
const {
|
const {
|
||||||
title,
|
title: rawTitle,
|
||||||
description,
|
description,
|
||||||
notes,
|
notes,
|
||||||
link,
|
link,
|
||||||
type,
|
event_date,
|
||||||
dimensions = [],
|
dimensions = [],
|
||||||
chunk,
|
chunk,
|
||||||
metadata = {}
|
metadata = {}
|
||||||
} = nodeData;
|
} = nodeData;
|
||||||
|
|
||||||
|
const title = sanitizeTitle(rawTitle);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const db = getDb();
|
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 nodeId = transaction(() => {
|
||||||
const stmt = db.prepare(`
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
title,
|
title,
|
||||||
description ?? null,
|
description ?? null,
|
||||||
content ?? null,
|
notes ?? null,
|
||||||
link ?? null,
|
link ?? null,
|
||||||
type ?? null,
|
event_date ?? null,
|
||||||
JSON.stringify(metadata),
|
JSON.stringify(metadata),
|
||||||
chunk ?? null,
|
chunkToStore,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@@ -146,11 +169,11 @@ function createNode(nodeData) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing node.
|
* 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 = {}) {
|
function updateNode(id, updates, options = {}) {
|
||||||
const { appendNotes = true } = 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 now = new Date().toISOString();
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -172,23 +195,23 @@ function updateNode(id, updates, options = {}) {
|
|||||||
setFields.push('description = ?');
|
setFields.push('description = ?');
|
||||||
params.push(description);
|
params.push(description);
|
||||||
}
|
}
|
||||||
if (content !== undefined) {
|
if (notes !== undefined) {
|
||||||
if (appendNotes && existing.notes) {
|
if (appendNotes && existing.notes) {
|
||||||
// Append to existing content
|
// Append to existing notes
|
||||||
setFields.push('content = ?');
|
setFields.push('notes = ?');
|
||||||
params.push(existing.notes + '\n\n' + content);
|
params.push(existing.notes + '\n\n' + notes);
|
||||||
} else {
|
} else {
|
||||||
setFields.push('content = ?');
|
setFields.push('notes = ?');
|
||||||
params.push(content);
|
params.push(notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (link !== undefined) {
|
if (link !== undefined) {
|
||||||
setFields.push('link = ?');
|
setFields.push('link = ?');
|
||||||
params.push(link);
|
params.push(link);
|
||||||
}
|
}
|
||||||
if (type !== undefined) {
|
if (event_date !== undefined) {
|
||||||
setFields.push('type = ?');
|
setFields.push('event_date = ?');
|
||||||
params.push(type);
|
params.push(event_date);
|
||||||
}
|
}
|
||||||
if (chunk !== undefined) {
|
if (chunk !== undefined) {
|
||||||
setFields.push('chunk = ?');
|
setFields.push('chunk = ?');
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ function initDatabase() {
|
|||||||
embedding BLOB,
|
embedding BLOB,
|
||||||
embedding_updated_at TEXT,
|
embedding_updated_at TEXT,
|
||||||
embedding_text TEXT,
|
embedding_text TEXT,
|
||||||
chunk_status TEXT DEFAULT 'not_chunked',
|
chunk_status TEXT DEFAULT 'not_chunked'
|
||||||
is_pinned INTEGER DEFAULT 0
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS edges (
|
CREATE TABLE IF NOT EXISTS edges (
|
||||||
@@ -71,7 +70,6 @@ function initDatabase() {
|
|||||||
source TEXT,
|
source TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
context TEXT,
|
context TEXT,
|
||||||
|
|
||||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (to_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 (
|
CREATE TABLE IF NOT EXISTS dimensions (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
icon TEXT,
|
||||||
is_priority INTEGER DEFAULT 0,
|
is_priority INTEGER DEFAULT 0,
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -446,9 +446,16 @@ mcpServer.registerTool(
|
|||||||
throw new McpError(ErrorCode.InvalidParams, 'At least one field must be provided in updates.');
|
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}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(updates)
|
body: JSON.stringify(mappedUpdates)
|
||||||
});
|
});
|
||||||
|
|
||||||
const node = result.node || result.data;
|
const node = result.node || result.data;
|
||||||
@@ -485,7 +492,7 @@ mcpServer.registerTool(
|
|||||||
nodes.push({
|
nodes.push({
|
||||||
id: result.node.id,
|
id: result.node.id,
|
||||||
title: result.node.title,
|
title: result.node.title,
|
||||||
content: result.node.content ?? null,
|
notes: result.node.notes ?? null,
|
||||||
link: result.node.link ?? null,
|
link: result.node.link ?? null,
|
||||||
dimensions: result.node.dimensions || [],
|
dimensions: result.node.dimensions || [],
|
||||||
updated_at: result.node.updated_at
|
updated_at: result.node.updated_at
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
|
|||||||
const packageJson = require('../../package.json');
|
const packageJson = require('../../package.json');
|
||||||
|
|
||||||
const instructions = [
|
const instructions = [
|
||||||
'Use rah.add_node to summarize conversations or files into nodes with dimensions.',
|
'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
|
||||||
'Use rah.search_nodes to recall prior notes before you suggest creating new ones.',
|
'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
|
||||||
'All operations happen locally on this device; data never leaves 127.0.0.1.'
|
'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(' ');
|
].join(' ');
|
||||||
|
|
||||||
const serverInfo = {
|
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 });
|
const server = new McpServer(serverInfo, { instructions });
|
||||||
|
|
||||||
function logError(...args) {
|
function logError(...args) {
|
||||||
@@ -375,9 +417,16 @@ server.registerTool(
|
|||||||
throw new Error('At least one field must be provided in updates.');
|
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}`, {
|
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(updates)
|
body: JSON.stringify(mappedUpdates)
|
||||||
});
|
});
|
||||||
|
|
||||||
const node = result.node || result.data;
|
const node = result.node || result.data;
|
||||||
@@ -414,7 +463,7 @@ server.registerTool(
|
|||||||
nodes.push({
|
nodes.push({
|
||||||
id: result.node.id,
|
id: result.node.id,
|
||||||
title: result.node.title,
|
title: result.node.title,
|
||||||
content: result.node.content ?? null,
|
notes: result.node.notes ?? null,
|
||||||
link: result.node.link ?? null,
|
link: result.node.link ?? null,
|
||||||
dimensions: result.node.dimensions || [],
|
dimensions: result.node.dimensions || [],
|
||||||
updated_at: result.node.updated_at
|
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() {
|
async function main() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
Reference in New Issue
Block a user