feat: port MCP retrieval and write hardening
- align os MCP/runtime/docs with ra-h contract - add safe direct node lookup and context-optional flows - gate edge and context writes behind confirmation
This commit is contained in:
@@ -5,7 +5,7 @@ Connect Claude Code and Claude Desktop to your RA-H knowledge base. Direct SQLit
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx ra-h-mcp-server
|
||||
npx --yes ra-h-mcp-server@2.1.1
|
||||
```
|
||||
|
||||
That's it. No manual setup required.
|
||||
@@ -19,7 +19,7 @@ Add to your Claude config (`~/.claude.json` or Claude Desktop settings):
|
||||
"mcpServers": {
|
||||
"ra-h": {
|
||||
"command": "npx",
|
||||
"args": ["ra-h-mcp-server"]
|
||||
"args": ["--yes", "ra-h-mcp-server@2.1.1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ Add to your Claude config (`~/.claude.json` or Claude Desktop settings):
|
||||
|
||||
Restart Claude. Done.
|
||||
|
||||
If you publish a newer MCP release and need this client to pick it up immediately, bump the pinned version here and restart Claude. Do not assume plain `npx ra-h-mcp-server` always refreshes instantly.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
@@ -44,13 +46,15 @@ Once connected, Claude will:
|
||||
- **Use `queryNodes` for explicit node lookup** when the user is trying to find a specific existing thing
|
||||
- **Use `retrieveQueryContext` when graph context is helpful** for a broader task, question, or request
|
||||
- **Use `getContext` only for orientation** when high-level graph state would actually help
|
||||
- **Treat context as optional by default** — normal node creation and updates should omit context unless the user explicitly wants one; when context is intentionally provided, use `context_name`
|
||||
- **Proactively capture knowledge** — when a new insight, decision, person, or reference surfaces, it proposes a specific node (title, description, optional context) so you can approve with minimal friction
|
||||
- **Treat edges as proposal-first** — it should suggest likely relationships briefly, then create them only after you explicitly confirm
|
||||
- **Read skills for complex tasks** — skills are editable and shared across internal + external agents
|
||||
- **Search before creating** to avoid duplicates
|
||||
|
||||
## Recommended Agent Memory Line
|
||||
|
||||
If you use external agents through this MCP server, add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.):
|
||||
If you use external agents through this MCP server, you may add one short instruction line to your agent memory file (`AGENTS.md`, `CLAUDE.md`, etc.) as optional reinforcement:
|
||||
|
||||
```md
|
||||
Retrieve relevant context from RA-H before substantive work, and only suggest writing durable context back when it is clearly valuable and the user can confirm yes.
|
||||
@@ -62,6 +66,8 @@ Keep the writeback prompt brief. A good pattern is:
|
||||
Add "X" as a node?
|
||||
```
|
||||
|
||||
RA-H should still work well without this line. The MCP tools, server instructions, skills, and docs are meant to carry the core behavior on their own.
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
@@ -74,8 +80,8 @@ Add "X" as a node?
|
||||
| `queryContexts` | List or inspect contexts |
|
||||
| `getNodesById` | Load nodes by ID (includes chunk + metadata) |
|
||||
| `updateNode` | Update an existing node |
|
||||
| `createEdge` | Create connection between nodes |
|
||||
| `updateEdge` | Update an edge explanation |
|
||||
| `createEdge` | Create a confirmed connection between nodes |
|
||||
| `updateEdge` | Update an edge explanation after explicit confirmation |
|
||||
| `queryEdge` | Find edges for a node |
|
||||
| `listSkills` | List available skills |
|
||||
| `readSkill` | Read a skill by name |
|
||||
@@ -86,6 +92,13 @@ Add "X" as a node?
|
||||
|
||||
## Node Metadata Contract
|
||||
|
||||
## Context Rule
|
||||
|
||||
- Creating a node never requires context.
|
||||
- Normal node lookup and update flows should omit context unless the user explicitly asks for it.
|
||||
- If context is intentionally provided, prefer `context_name`.
|
||||
- Numeric `context_id` is treated as an internal implementation detail rather than a normal agent-facing field.
|
||||
|
||||
When `createNode` or `updateNode` includes metadata, prefer the canonical shape:
|
||||
|
||||
```json
|
||||
@@ -112,6 +125,12 @@ Rules:
|
||||
- Keep the ask terse and concrete, for example: `Add "X" as a node?`
|
||||
- Never call `writeContext` unless the user has explicitly said yes.
|
||||
|
||||
## Edge Rule
|
||||
|
||||
- External agents should propose likely edge candidates first.
|
||||
- `createEdge` is the execution tool after explicit user confirmation.
|
||||
- Agent-driven edge creation should always include a clear explanation sentence.
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are detailed instruction sets that teach agents how to work with your knowledge base. The default seeded skills are editable and shared by internal + external agents.
|
||||
|
||||
@@ -35,6 +35,7 @@ const edgeService = require('./services/edgeService');
|
||||
const contextService = require('./services/contextService');
|
||||
const skillService = require('./services/skillService');
|
||||
const retrievalService = require('./services/retrievalService');
|
||||
const { directNodeLookup } = require('./services/directNodeLookupService');
|
||||
|
||||
// Server info
|
||||
const serverInfo = {
|
||||
@@ -68,15 +69,17 @@ function buildInstructions() {
|
||||
6. For complex tasks, call readSkill("db-operations").
|
||||
|
||||
## Context field rule
|
||||
`context_id` is optional on writes.
|
||||
Do not include `context_id` unless you already know a real existing context ID that clearly fits.
|
||||
Omitting `context_id` is the normal default and does not block create or update operations.
|
||||
Context is optional on writes.
|
||||
Do not include any context field unless the user explicitly wants one.
|
||||
If context is intentionally provided, prefer \`context_name\`. Treat numeric \`context_id\` as an internal implementation detail.
|
||||
Omitting context is the normal default and does not block create or update operations.
|
||||
|
||||
## Knowledge capture
|
||||
Only suggest saving context when it seems unusually durable and valuable.
|
||||
Keep the ask brief: Add "X" as a node?
|
||||
Do not pester. Do not keep re-asking if the user says no, ignores it, or moves on.
|
||||
Never write via writeContext unless the user has explicitly confirmed yes.
|
||||
Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.
|
||||
Always search or retrieve before creating to avoid duplicates.
|
||||
|
||||
## Available skills
|
||||
@@ -93,16 +96,15 @@ const addNodeInputSchema = {
|
||||
source: z.string().max(50000).optional().describe('Canonical source content for embedding'),
|
||||
link: z.string().url().optional().describe('Source URL'),
|
||||
description: z.string().optional().describe('Strongly recommended. Write the description as natural prose, not labels or a checklist. It should make clear what the artifact is and any surrounding context available. RA-H will accept whatever description is provided and will not block the write.'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'),
|
||||
context_name: z.string().optional().describe('Optional convenience context name.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional().describe('Legacy alias for 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)'),
|
||||
contextId: z.number().int().positive().optional().describe('Optional primary context filter.'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 10)'),
|
||||
context_name: z.string().optional().describe('Optional primary context name filter. Use only when the user explicitly wants a context-specific lookup.'),
|
||||
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.'),
|
||||
@@ -120,7 +122,7 @@ const writeContextInputSchema = {
|
||||
title: z.string().min(1).max(160).describe('Clear proposed node title'),
|
||||
description: z.string().min(1).max(500).describe('Natural description of what this context is and why it matters'),
|
||||
source: z.string().max(50000).optional().describe('Optional source or verbatim user wording to preserve'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this saved under a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata patch'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true before the write is allowed')
|
||||
};
|
||||
@@ -137,7 +139,8 @@ const updateNodeInputSchema = {
|
||||
content: z.string().optional().describe('Legacy alias for source'),
|
||||
source: z.string().optional().describe('Canonical source content for embedding'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
|
||||
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. It now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
@@ -145,12 +148,14 @@ const updateNodeInputSchema = {
|
||||
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'")
|
||||
explanation: z.string().min(1).describe("Human-readable explanation. Should read as a sentence: 'Alice invented this technique'"),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.')
|
||||
};
|
||||
|
||||
const updateEdgeInputSchema = {
|
||||
id: z.number().int().positive().describe('Edge ID'),
|
||||
explanation: z.string().min(1).describe('Updated explanation for this connection')
|
||||
explanation: z.string().min(1).describe('Updated explanation for this connection'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.')
|
||||
};
|
||||
|
||||
const queryEdgesInputSchema = {
|
||||
@@ -358,16 +363,16 @@ async function main() {
|
||||
'createNode',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.',
|
||||
description: 'Create a new node. Always search first (queryNodes) to avoid duplicates. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID. Title: max 160 chars, clear and descriptive. Description is strongly recommended and should explicitly describe what the thing is and any surrounding context available, but the write will never be blocked over description quality. Use "link" ONLY for external content (URL, video, article) — omit for synthesis/ideas derived from existing nodes. "source" = verbatim or canonical content for embedding. Legacy "content" and "chunk" are mapped to source for compatibility.',
|
||||
inputSchema: addNodeInputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
async ({ title, content, source, link, description, context_name, metadata, chunk }) => {
|
||||
const sourceText = source?.trim() || content?.trim() || chunk?.trim();
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : description;
|
||||
|
||||
let resolvedContextId;
|
||||
try {
|
||||
resolvedContextId = contextService.resolveContextId({ context_id, context_name });
|
||||
resolvedContextId = contextService.resolveContextId({ context_name });
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
@@ -398,37 +403,36 @@ async function main() {
|
||||
'queryNodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Search nodes by keyword across title, description, and source fields using the same indexed search path as the app search UI. Use this for direct node lookup or duplicate checks. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
|
||||
description: 'Search nodes by keyword across title, description, and source fields using the same safe direct-lookup behavior as the app. Use this for direct node lookup or duplicate checks. Leave context blank by default. If the user explicitly wants a context-specific lookup, use context_name rather than a numeric ID. For full current-turn grounding of a substantive query, prefer retrieveQueryContext. NOT for searching source documents (transcripts, articles) — use searchContentEmbeddings for that.',
|
||||
inputSchema: searchNodesInputSchema
|
||||
},
|
||||
async ({ query: searchQuery, limit = 10, contextId, created_after, created_before, event_after, event_before }) => {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 25);
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
const nodes = nodeService.getNodes({
|
||||
search: trimmedQuery,
|
||||
async ({ query: searchQuery, limit = 10, context_name, created_after, created_before, event_after, event_before }) => {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 50);
|
||||
const result = directNodeLookup({
|
||||
search: searchQuery.trim(),
|
||||
limit: safeLimit,
|
||||
contextId,
|
||||
context_name,
|
||||
createdAfter: created_after,
|
||||
createdBefore: created_before,
|
||||
eventAfter: event_after,
|
||||
eventBefore: event_before,
|
||||
});
|
||||
|
||||
const summary = nodes.length === 0
|
||||
const summary = result.count === 0
|
||||
? 'No nodes found matching that query.'
|
||||
: `Found ${nodes.length} node(s).`;
|
||||
: `Found ${result.count} node(s).`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes: nodes.map((node) => ({
|
||||
count: result.count,
|
||||
filters_applied: result.filtersApplied,
|
||||
nodes: result.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
source: node.source ?? null,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
context_id: node.context_id ?? null,
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
event_date: node.event_date ?? null,
|
||||
@@ -442,19 +446,26 @@ async function main() {
|
||||
'writeContext',
|
||||
{
|
||||
title: 'Write RA-H context node',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.',
|
||||
inputSchema: writeContextInputSchema
|
||||
},
|
||||
async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
|
||||
async ({ title, description, source, context_name, metadata, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('writeContext requires explicit user confirmation before writing to the graph.');
|
||||
}
|
||||
|
||||
let resolvedContextId;
|
||||
try {
|
||||
resolvedContextId = contextService.resolveContextId({ context_name });
|
||||
} catch (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const node = nodeService.createNode({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim(),
|
||||
context_id: context_id ?? null,
|
||||
context_id: resolvedContextId,
|
||||
metadata: {
|
||||
captured_by: 'human',
|
||||
captured_method: 'write_context',
|
||||
@@ -527,7 +538,7 @@ async function main() {
|
||||
'updateNode',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
|
||||
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants the context removed. Description updates should explicitly state what this thing is and any surrounding context available, but the write will never be blocked over description quality. Source content lives in "source". Legacy "content" is mapped to source for compatibility. Title, description, and link are overwritten. Call getNodesById first to verify current state before updating.',
|
||||
inputSchema: updateNodeInputSchema
|
||||
},
|
||||
async ({ id, updates }) => {
|
||||
@@ -552,10 +563,19 @@ async function main() {
|
||||
: mappedUpdates.description;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) {
|
||||
mappedUpdates.context_id = contextService.resolveContextId({ context_id: mappedUpdates.context_id });
|
||||
if (mappedUpdates.context_name && mappedUpdates.clear_context) {
|
||||
throw new Error('context_name cannot be combined with clear_context: true.');
|
||||
}
|
||||
|
||||
if (mappedUpdates.context_name || mappedUpdates.clear_context || Object.prototype.hasOwnProperty.call(mappedUpdates, 'context_id')) {
|
||||
mappedUpdates.context_id = contextService.resolveContextId({
|
||||
context_id: mappedUpdates.clear_context ? null : mappedUpdates.context_id,
|
||||
context_name: mappedUpdates.context_name,
|
||||
});
|
||||
}
|
||||
delete mappedUpdates.context_name;
|
||||
delete mappedUpdates.clear_context;
|
||||
|
||||
const node = nodeService.updateNode(id, mappedUpdates);
|
||||
const message = `Updated node #${id}`;
|
||||
|
||||
@@ -576,10 +596,14 @@ async function main() {
|
||||
'createEdge',
|
||||
{
|
||||
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"). Call queryEdge first to check if a connection already exists between the two nodes.',
|
||||
description: 'Connect two nodes with an edge only after the user has explicitly confirmed the proposed relationship. 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"). Call queryEdge first to check if a connection already exists between the two nodes.',
|
||||
inputSchema: createEdgeInputSchema
|
||||
},
|
||||
async ({ sourceId, targetId, explanation }) => {
|
||||
async ({ sourceId, targetId, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('createEdge requires explicit user confirmation before writing the relationship.');
|
||||
}
|
||||
|
||||
const edge = edgeService.createEdge({
|
||||
from_node_id: sourceId,
|
||||
to_node_id: targetId,
|
||||
@@ -602,10 +626,14 @@ async function main() {
|
||||
'updateEdge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an edge explanation. Use when a connection needs a better or corrected explanation.',
|
||||
description: 'Update an edge explanation only after the user explicitly confirmed the corrected relationship.',
|
||||
inputSchema: updateEdgeInputSchema
|
||||
},
|
||||
async ({ id, explanation }) => {
|
||||
async ({ id, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('updateEdge requires explicit user confirmation before writing the corrected relationship.');
|
||||
}
|
||||
|
||||
const edge = edgeService.updateEdge(id, { explanation: explanation.trim() });
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ra-h-mcp-server",
|
||||
"version": "1.10.1",
|
||||
"version": "2.1.1",
|
||||
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
|
||||
"main": "index.js",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use strict';
|
||||
|
||||
const nodeService = require('./nodeService');
|
||||
const contextService = require('./contextService');
|
||||
|
||||
const SEARCH_STOP_WORDS = new Set([
|
||||
'a', 'about', 'added', 'already', 'an', 'and', 'are', 'as', 'at', 'be', 'by',
|
||||
'can', 'created', 'do', 'find', 'for', 'from', 'hello', 'i', 'in', 'into', 'is',
|
||||
'it', 'just', 'look', 'me', 'my', 'node', 'of', 'on', 'or', 'pull', 'recent',
|
||||
'recently', 'saved', 'shared', 'show', 'some', 'stuff', 'term', 'that', 'the', 'this',
|
||||
'to', 'versus', 'were', 'what', 'with', 'wrote', 'you', 'doing', 'going', 'having',
|
||||
]);
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/\b([a-z0-9]{1,3})-([a-z0-9]{1,3})(?:-([a-z0-9]{1,3}))?\b/gi, (_match, a, b, c) => `${a}${b}${c || ''}`)
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function singularizeTerm(term) {
|
||||
if (term.endsWith('ies') && term.length > 4) return `${term.slice(0, -3)}y`;
|
||||
if (term.endsWith('s') && term.length > 4 && !term.endsWith('ss') && !term.endsWith('us')) {
|
||||
return term.slice(0, -1);
|
||||
}
|
||||
return term;
|
||||
}
|
||||
|
||||
function getHighSignalSearchTerms(query) {
|
||||
const seen = new Set();
|
||||
const terms = [];
|
||||
|
||||
for (const rawTerm of normalizeSearchText(query).split(' ')) {
|
||||
const term = singularizeTerm(rawTerm.trim());
|
||||
if (!term || term.length < 3) continue;
|
||||
if (SEARCH_STOP_WORDS.has(term)) continue;
|
||||
if (seen.has(term)) continue;
|
||||
seen.add(term);
|
||||
terms.push(term);
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
function countHighSignalQueryTermMatches(node, query) {
|
||||
const terms = getHighSignalSearchTerms(query);
|
||||
if (terms.length === 0) return 0;
|
||||
|
||||
const haystack = [
|
||||
normalizeSearchText(node.title || ''),
|
||||
normalizeSearchText(node.description || ''),
|
||||
normalizeSearchText(node.source || ''),
|
||||
].join(' ');
|
||||
|
||||
return terms.filter(term => haystack.includes(term)).length;
|
||||
}
|
||||
|
||||
function countOccurrences(text, term) {
|
||||
if (!text || !term) return 0;
|
||||
const matches = text.match(new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'));
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function orderedTermMatches(text, terms) {
|
||||
let position = 0;
|
||||
for (const term of terms) {
|
||||
const index = text.indexOf(term, position);
|
||||
if (index === -1) return false;
|
||||
position = index + term.length;
|
||||
}
|
||||
return terms.length > 0;
|
||||
}
|
||||
|
||||
function scoreNodeSearchMatch(node, query) {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const normalizedTitle = normalizeSearchText(node.title || '');
|
||||
const normalizedDescription = normalizeSearchText(node.description || '');
|
||||
const normalizedSource = normalizeSearchText(node.source || '');
|
||||
const terms = getHighSignalSearchTerms(query);
|
||||
|
||||
let score = 0;
|
||||
|
||||
if (normalizedTitle === normalizedQuery) score += 2000;
|
||||
if (normalizedTitle.startsWith(normalizedQuery)) score += 1200;
|
||||
if (normalizedTitle.includes(normalizedQuery)) score += 700;
|
||||
if (orderedTermMatches(normalizedTitle, terms)) score += 500;
|
||||
if (terms.length > 0 && terms.every(term => normalizedTitle.includes(term))) score += 350;
|
||||
|
||||
if (normalizedDescription.includes(normalizedQuery)) score += 180;
|
||||
if (orderedTermMatches(normalizedDescription, terms)) score += 120;
|
||||
if (normalizedSource.includes(normalizedQuery)) score += 90;
|
||||
|
||||
const matchedTermCount = countHighSignalQueryTermMatches(node, query);
|
||||
score += matchedTermCount * 120;
|
||||
if (terms.length > 0 && matchedTermCount === terms.length) score += 300;
|
||||
|
||||
for (const term of terms) {
|
||||
score += countOccurrences(normalizedTitle, term) * 40;
|
||||
score += countOccurrences(normalizedDescription, term) * 8;
|
||||
score += countOccurrences(normalizedSource, term) * 3;
|
||||
}
|
||||
|
||||
if (node.updated_at) {
|
||||
score += new Date(node.updated_at).getTime() / 1e13;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function normalizeContextName(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function resolveSearchContext({ context_name, contextId }) {
|
||||
const normalizedName = normalizeContextName(context_name);
|
||||
if (normalizedName) {
|
||||
const context = contextService.getContextByName(normalizedName);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookupService received unknown context_name "${normalizedName}"; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof contextId === 'number') {
|
||||
const context = contextService.getContextById(contextId);
|
||||
if (!context) {
|
||||
console.warn(`directNodeLookupService received invalid legacy contextId ${contextId}; ignoring context filter.`);
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
contextId: context.id,
|
||||
context_name: context.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function hasStrongAnchorMatch(nodes, searchTerm) {
|
||||
if (!searchTerm || nodes.length === 0) return false;
|
||||
const highSignalTerms = getHighSignalSearchTerms(searchTerm);
|
||||
const requiredMatches = Math.min(2, highSignalTerms.length || 1);
|
||||
return nodes.some(node => countHighSignalQueryTermMatches(node, searchTerm) >= requiredMatches);
|
||||
}
|
||||
|
||||
function directNodeLookup(input = {}) {
|
||||
const limit = Math.min(Math.max(input.limit || 10, 1), 50);
|
||||
const searchTerm = typeof input.search === 'string' ? input.search.trim() : '';
|
||||
|
||||
if (searchTerm && /^\d+$/.test(searchTerm)) {
|
||||
const nodeId = Number(searchTerm);
|
||||
const node = nodeService.getNodeById(nodeId);
|
||||
return {
|
||||
nodes: node ? [node] : [],
|
||||
count: node ? 1 : 0,
|
||||
filtersApplied: {
|
||||
search: searchTerm,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedContext = resolveSearchContext(input);
|
||||
|
||||
let safeNodes = nodeService.getNodes({
|
||||
search: searchTerm || undefined,
|
||||
limit,
|
||||
contextId: resolvedContext.contextId,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
});
|
||||
|
||||
const hadExtraFilters = Boolean(
|
||||
resolvedContext.contextId !== undefined ||
|
||||
input.createdAfter ||
|
||||
input.createdBefore ||
|
||||
input.eventAfter ||
|
||||
input.eventBefore
|
||||
);
|
||||
|
||||
if (searchTerm && hadExtraFilters && (safeNodes.length === 0 || !hasStrongAnchorMatch(safeNodes, searchTerm))) {
|
||||
console.warn(`directNodeLookupService falling back to plain literal search for "${searchTerm}" after filtered lookup missed a strong anchor match.`);
|
||||
safeNodes = nodeService.searchNodes({ search: searchTerm, limit });
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
safeNodes = safeNodes
|
||||
.map(node => ({ node, score: scoreNodeSearchMatch(node, searchTerm) }))
|
||||
.sort((a, b) => b.score - a.score || String(b.node.updated_at || '').localeCompare(String(a.node.updated_at || '')))
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.node);
|
||||
} else {
|
||||
safeNodes = safeNodes.slice(0, limit);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: safeNodes,
|
||||
count: safeNodes.length,
|
||||
filtersApplied: {
|
||||
search: searchTerm || undefined,
|
||||
limit,
|
||||
context_name: resolvedContext.context_name,
|
||||
createdAfter: input.createdAfter,
|
||||
createdBefore: input.createdBefore,
|
||||
eventAfter: input.eventAfter,
|
||||
eventBefore: input.eventBefore,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
directNodeLookup,
|
||||
};
|
||||
@@ -158,6 +158,69 @@ function sanitizeTitle(title) {
|
||||
return clean.slice(0, 160);
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 1000;
|
||||
const CHUNK_OVERLAP = 200;
|
||||
|
||||
function splitSourceIntoChunks(sourceText) {
|
||||
const text = String(sourceText || '').trim();
|
||||
if (!text) return [];
|
||||
|
||||
const chunks = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + CHUNK_SIZE, text.length);
|
||||
const chunkText = text.slice(start, end).trim();
|
||||
|
||||
if (chunkText) {
|
||||
chunks.push({
|
||||
text: chunkText,
|
||||
start_char: start,
|
||||
end_char: end
|
||||
});
|
||||
}
|
||||
|
||||
if (end >= text.length) break;
|
||||
start = Math.max(end - CHUNK_OVERLAP, start + 1);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function replaceNodeChunks(db, nodeId, title, sourceText) {
|
||||
db.prepare('DELETE FROM chunks WHERE node_id = ?').run(nodeId);
|
||||
|
||||
const chunks = splitSourceIntoChunks(sourceText);
|
||||
if (chunks.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const insertChunk = db.prepare(`
|
||||
INSERT INTO chunks (node_id, chunk_idx, text, embedding_type, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
insertChunk.run(
|
||||
nodeId,
|
||||
index,
|
||||
chunk.text,
|
||||
'text-embedding-3-small',
|
||||
JSON.stringify({
|
||||
node_id: nodeId,
|
||||
chunk_index: index,
|
||||
start_char: chunk.start_char,
|
||||
end_char: chunk.end_char,
|
||||
title
|
||||
}),
|
||||
now
|
||||
);
|
||||
});
|
||||
|
||||
return chunks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node.
|
||||
*/
|
||||
@@ -180,11 +243,12 @@ function createNode(nodeData) {
|
||||
|
||||
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||
const effectiveContextId = context_id ?? null;
|
||||
const hasSource = !!normalizeString(sourceToStore);
|
||||
|
||||
const nodeId = transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, context_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO nodes (title, description, source, link, event_date, metadata, chunk_status, context_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
@@ -194,6 +258,7 @@ function createNode(nodeData) {
|
||||
link ?? null,
|
||||
event_date ?? null,
|
||||
JSON.stringify(canonicalMetadata),
|
||||
hasSource ? 'chunked' : null,
|
||||
effectiveContextId ?? null,
|
||||
now,
|
||||
now
|
||||
@@ -201,6 +266,10 @@ function createNode(nodeData) {
|
||||
|
||||
const id = Number(result.lastInsertRowid);
|
||||
|
||||
if (hasSource) {
|
||||
replaceNodeChunks(db, id, title, sourceToStore);
|
||||
}
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -224,6 +293,9 @@ function updateNode(id, updates, options = {}) {
|
||||
const mergedMetadata = metadata !== undefined
|
||||
? buildCanonicalMetadata({ existing: existing.metadata, metadata })
|
||||
: undefined;
|
||||
const sourceWasProvided = Object.prototype.hasOwnProperty.call(updates, 'source');
|
||||
const updatedTitle = title !== undefined ? title : existing.title;
|
||||
const normalizedSource = sourceWasProvided ? normalizeString(source) : undefined;
|
||||
|
||||
transaction(() => {
|
||||
const setFields = [];
|
||||
@@ -249,6 +321,10 @@ function updateNode(id, updates, options = {}) {
|
||||
setFields.push('event_date = ?');
|
||||
params.push(event_date);
|
||||
}
|
||||
if (sourceWasProvided) {
|
||||
setFields.push('chunk_status = ?');
|
||||
params.push(normalizedSource ? 'chunked' : null);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'context_id')) {
|
||||
setFields.push('context_id = ?');
|
||||
params.push(updates.context_id ?? null);
|
||||
@@ -268,6 +344,10 @@ function updateNode(id, updates, options = {}) {
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
if (sourceWasProvided) {
|
||||
replaceNodeChunks(db, id, updatedTitle, normalizedSource || '');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return getNodeById(id);
|
||||
|
||||
@@ -13,8 +13,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
4. Search before create to avoid duplicates.
|
||||
5. Every create/update must include a natural description that clearly says what the thing is, why it matters here, and its current workflow status.
|
||||
6. Use event dates when known (when it happened, not when saved).
|
||||
7. Apply context only when it is an obvious match to one of the user's existing contexts and genuinely useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
8. Create edges when relationships are meaningful; edge explanations should read as a sentence.
|
||||
7. Leave context blank by default. Only apply context when the user explicitly wants it or when one obvious existing context is clearly useful. One node gets at most one primary context, and leaving context blank is valid.
|
||||
8. Do not create edges autonomously. Surface likely edge candidates first, then create them only after the user explicitly confirms.
|
||||
9. For user-authored ideas, notes, or dictated thoughts, preserve the user's wording in `source` as fully as possible with only minimal cleanup.
|
||||
|
||||
## Write Quality Contract
|
||||
@@ -24,7 +24,9 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
- `source`: full verbatim or canonical content of the node (transcript, article text, book passage, user's thoughts). This is what gets chunked and embedded for semantic search.
|
||||
- For idea capture from chat, the `source` should usually be the raw user thought, not a compressed assistant summary.
|
||||
- `link`: external source URL only.
|
||||
- `context_id`: the node's primary context. This field is optional. Omit it entirely unless it is an obvious existing match. Do not add `context_id: null` defensively.
|
||||
- Normal writes should omit context entirely unless the user explicitly wants one.
|
||||
- If context is intentionally provided, prefer `context_name`.
|
||||
- Treat numeric `context_id` as an internal implementation detail, not a normal agent-facing field.
|
||||
- `metadata`: use the canonical node metadata contract when metadata is needed:
|
||||
- `type`
|
||||
- `state` (`processed` or `not_processed`)
|
||||
@@ -34,6 +36,8 @@ description: "Use for graph read, write, connect, classify, or traverse operatio
|
||||
- `source_metadata`: factual source-specific details only. Keep it compact. No AI summaries or reasoning text.
|
||||
- metadata updates are merge-safe patches, not full-blob replacements. Do not assume `updateNode.metadata` wipes existing keys.
|
||||
- Derived analysis, briefs, and research notes should be stored in a separate linked node, not appended to the source node.
|
||||
- Explicit user-directed capture may write immediately after duplicate/update checks when the node is clear.
|
||||
- Agent-suggested capture should propose the node first and wait for confirmation.
|
||||
|
||||
## Description Standard
|
||||
|
||||
@@ -57,6 +61,8 @@ For user-authored idea capture, do not treat the inferred description as final i
|
||||
- why it belongs here
|
||||
- where it sits in their workflow
|
||||
|
||||
The first job of the description is object identity. It should start from what the thing is, not from interpretation.
|
||||
|
||||
Max 500 characters.
|
||||
|
||||
## Metadata Semantics
|
||||
@@ -71,17 +77,19 @@ Max 500 characters.
|
||||
1. Decide whether this is direct node retrieval or broader contextual grounding.
|
||||
2. If the user is trying to find a specific existing node, call `queryNodes` first.
|
||||
3. If the user is asking a broader question that would benefit from prior graph context, call `retrieveQueryContext`.
|
||||
4. Decide: answer only vs create vs update vs connect.
|
||||
4. Decide: answer only vs create vs update vs propose save vs propose edge.
|
||||
5. If something seems unusually durable and valuable, you may suggest a save in one short line like `Add "X" as a node?`
|
||||
6. Do not pester. If the user says no, ignores it, or moves on, do not keep asking.
|
||||
7. Only call `writeContext` or another write tool after explicit user confirmation.
|
||||
8. Execute minimum required writes.
|
||||
9. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
10. Verify result reflects user intent exactly.
|
||||
7. For explicit user-directed capture, search before create when practical, prefer update over duplicate create, then write once the artifact is clear.
|
||||
8. For agent-suggested capture, propose the node first and wait for explicit confirmation before writing.
|
||||
9. When relationships are obvious, include brief proposed edges in the same reply, then wait for confirmation before calling `createEdge`.
|
||||
10. If the node is a user-authored idea and the contextual framing was inferred, offer one concise feedback pass after the write.
|
||||
11. Verify result reflects user intent exactly.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Create duplicate nodes when an update is correct.
|
||||
- Write vague descriptions ("discusses", "explores", "is about").
|
||||
- Create weak or directionless edges.
|
||||
- Create edges before the user explicitly confirms the proposed relationship.
|
||||
- Ask to save every moderately useful point from the conversation.
|
||||
|
||||
+45
-36
@@ -53,6 +53,7 @@ const instructions = [
|
||||
'Search before creating: use rah_search_nodes to check if content already exists.',
|
||||
'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?',
|
||||
'Never write via rah_write_context unless the user has explicitly confirmed yes.',
|
||||
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
|
||||
'Every edge needs an explanation: why does this connection exist?',
|
||||
'All data stays local on this device; nothing leaves 127.0.0.1.',
|
||||
].join(' ');
|
||||
@@ -78,8 +79,7 @@ const addNodeInputSchema = {
|
||||
source: z.string().max(50000).optional(),
|
||||
link: z.string().url().optional(),
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'),
|
||||
context_name: z.string().optional(),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional()
|
||||
};
|
||||
@@ -92,8 +92,8 @@ const addNodeOutputSchema = {
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
contextId: z.number().int().positive().optional()
|
||||
limit: z.number().min(1).max(50).optional(),
|
||||
context_name: z.string().optional()
|
||||
};
|
||||
|
||||
const searchNodesOutputSchema = {
|
||||
@@ -147,7 +147,7 @@ const writeContextInputSchema = {
|
||||
title: z.string().min(1).max(160),
|
||||
description: z.string().min(1).max(500),
|
||||
source: z.string().max(50000).optional(),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
confirmed_by_user: z.boolean()
|
||||
};
|
||||
@@ -199,7 +199,8 @@ const updateNodeInputSchema = {
|
||||
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
|
||||
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
|
||||
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
@@ -232,7 +233,8 @@ const getNodesOutputSchema = {
|
||||
const createEdgeInputSchema = {
|
||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||
targetId: z.number().int().positive().describe('Target node ID'),
|
||||
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.')
|
||||
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.')
|
||||
};
|
||||
|
||||
const createEdgeOutputSchema = {
|
||||
@@ -263,7 +265,8 @@ const queryEdgesOutputSchema = {
|
||||
// rah_update_edge schemas
|
||||
const updateEdgeInputSchema = {
|
||||
id: z.number().int().positive().describe('Edge ID to update'),
|
||||
explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)')
|
||||
explanation: z.string().min(1).describe('New explanation text (will re-infer relationship type)'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.')
|
||||
};
|
||||
|
||||
const updateEdgeOutputSchema = {
|
||||
@@ -375,17 +378,16 @@ mcpServer.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits.',
|
||||
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.',
|
||||
inputSchema: addNodeInputSchema,
|
||||
outputSchema: addNodeOutputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
async ({ title, content, source, link, description, context_name, metadata, chunk }) => {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
metadata: metadata || {}
|
||||
};
|
||||
@@ -413,24 +415,21 @@ mcpServer.registerTool(
|
||||
'rah_search_nodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
|
||||
inputSchema: searchNodesInputSchema,
|
||||
outputSchema: searchNodesOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
|
||||
if (contextId) {
|
||||
params.set('contextId', String(contextId));
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
async ({ query, limit = 10, context_name }) => {
|
||||
const result = await callRaHApi('/api/nodes/direct-search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query: query.trim(),
|
||||
limit: Math.min(Math.max(limit, 1), 50),
|
||||
context_name: typeof context_name === 'string' ? context_name.trim() : undefined,
|
||||
})
|
||||
});
|
||||
|
||||
const nodes = Array.isArray(result.data) ? result.data : [];
|
||||
const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : [];
|
||||
const summary = nodes.length === 0
|
||||
? 'No existing RA-H nodes mention that topic yet.'
|
||||
: `Found ${nodes.length} node(s) mentioning that topic.`;
|
||||
@@ -565,7 +564,7 @@ mcpServer.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context.',
|
||||
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
@@ -585,6 +584,10 @@ mcpServer.registerTool(
|
||||
}
|
||||
delete mappedUpdates.chunk;
|
||||
|
||||
if (mappedUpdates.context_name && mappedUpdates.clear_context) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'context_name cannot be combined with clear_context: true.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(mappedUpdates)
|
||||
@@ -648,17 +651,22 @@ mcpServer.registerTool(
|
||||
'rah_create_edge',
|
||||
{
|
||||
title: 'Create RA-H edge',
|
||||
description: 'Create a connection between two nodes.',
|
||||
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
|
||||
inputSchema: createEdgeInputSchema,
|
||||
outputSchema: createEdgeOutputSchema
|
||||
},
|
||||
async ({ sourceId, targetId, explanation }) => {
|
||||
async ({ sourceId, targetId, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'rah_create_edge requires explicit user confirmation before writing the relationship.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_node_id: sourceId,
|
||||
to_node_id: targetId,
|
||||
explanation: explanation.trim(),
|
||||
source: 'helper_name',
|
||||
created_via: 'mcp'
|
||||
created_via: 'mcp',
|
||||
confirmed_by_user: true
|
||||
};
|
||||
|
||||
const result = await callRaHApi('/api/edges', {
|
||||
@@ -716,19 +724,20 @@ mcpServer.registerTool(
|
||||
'rah_update_edge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an existing edge connection.',
|
||||
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
|
||||
inputSchema: updateEdgeInputSchema,
|
||||
outputSchema: updateEdgeOutputSchema
|
||||
},
|
||||
async ({ id, explanation }) => {
|
||||
if (typeof explanation !== 'string' || explanation.trim().length === 0) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'explanation is required.');
|
||||
async ({ id, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new McpError(ErrorCode.InvalidParams, 'rah_update_edge requires explicit user confirmation before writing the corrected relationship.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
context: { explanation: explanation.trim(), created_via: 'mcp' }
|
||||
context: { explanation: explanation.trim(), created_via: 'mcp' },
|
||||
confirmed_by_user: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -902,11 +911,11 @@ mcpServer.registerTool(
|
||||
'rah_write_context',
|
||||
{
|
||||
title: 'Write RA-H context node',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.',
|
||||
inputSchema: writeContextInputSchema,
|
||||
outputSchema: writeContextOutputSchema
|
||||
},
|
||||
async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
|
||||
async ({ title, description, source, context_name, metadata, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.');
|
||||
}
|
||||
@@ -917,7 +926,7 @@ mcpServer.registerTool(
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim() || undefined,
|
||||
context_id: context_id ?? null,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
metadata: {
|
||||
captured_by: 'human',
|
||||
captured_method: 'write_context',
|
||||
|
||||
@@ -20,6 +20,7 @@ const instructions = [
|
||||
'Search before creating: use rah_search_nodes to check if content already exists.',
|
||||
'Only suggest saving context when it is unusually durable and valuable. Keep the ask brief, for example: Add "X" as a node?',
|
||||
'Never write via rah_write_context unless the user has explicitly confirmed yes.',
|
||||
'Do not create edges autonomously. Surface likely edge candidates briefly, then call edge-write tools only after the user explicitly confirms.',
|
||||
'Every edge needs an explanation: why does this connection exist?',
|
||||
'All data stays local on this device; nothing leaves 127.0.0.1.',
|
||||
].join(' ');
|
||||
@@ -44,8 +45,7 @@ const addNodeInputSchema = {
|
||||
source: z.string().max(50000).optional(),
|
||||
link: z.string().url().optional(),
|
||||
description: z.string().max(500).optional().describe('Description of the node. Write it as natural prose, not labels or a checklist. It must still make clear what the artifact is, why it is in the graph (infer from conversation context; ask the user if needed), and its current workflow status. Max 500 characters. If the reason is unclear, say that naturally instead of inventing it. Never use filler phrases like "insightful for understanding" or "relevant to the user\'s work".'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Usually omit this field entirely unless you already know a real matching context.'),
|
||||
context_name: z.string().optional(),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants this node assigned to a known context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Optional metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.'),
|
||||
chunk: z.string().max(50000).optional()
|
||||
};
|
||||
@@ -58,8 +58,8 @@ const addNodeOutputSchema = {
|
||||
|
||||
const searchNodesInputSchema = {
|
||||
query: z.string().min(1).max(400),
|
||||
limit: z.number().min(1).max(25).optional(),
|
||||
contextId: z.number().int().positive().optional()
|
||||
limit: z.number().min(1).max(50).optional(),
|
||||
context_name: z.string().optional()
|
||||
};
|
||||
|
||||
const searchNodesOutputSchema = {
|
||||
@@ -113,7 +113,7 @@ const writeContextInputSchema = {
|
||||
title: z.string().min(1).max(160),
|
||||
description: z.string().min(1).max(500),
|
||||
source: z.string().max(50000).optional(),
|
||||
context_id: z.number().int().positive().nullable().optional(),
|
||||
context_name: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
confirmed_by_user: z.boolean()
|
||||
};
|
||||
@@ -165,7 +165,8 @@ const updateNodeInputSchema = {
|
||||
content: z.string().optional().describe('Legacy alias for source. Mapped to source for backward compatibility.'),
|
||||
source: z.string().optional().describe('Canonical source text for embedding.'),
|
||||
link: z.string().optional().describe('New link'),
|
||||
context_id: z.number().int().positive().nullable().optional().describe('Optional primary context ID. Omit this field to preserve existing context. Only use null when you intentionally want to clear context.'),
|
||||
context_name: z.string().optional().describe('Optional primary context name. Use only when the user explicitly wants to assign this node to a known context.'),
|
||||
clear_context: z.boolean().optional().describe('Set true only when the user explicitly wants to remove the node context.'),
|
||||
metadata: z.record(z.any()).optional().describe('Metadata patch. This now merges with existing metadata. Prefer canonical keys: type, state, captured_method, captured_by, source_metadata.')
|
||||
}).describe('Fields to update')
|
||||
};
|
||||
@@ -198,7 +199,8 @@ const getNodesOutputSchema = {
|
||||
const createEdgeInputSchema = {
|
||||
sourceId: z.number().int().positive().describe('Source node ID'),
|
||||
targetId: z.number().int().positive().describe('Target node ID'),
|
||||
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.')
|
||||
explanation: z.string().min(1).describe('REQUIRED: Why does this connection exist? Be specific.'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only create the edge after the user explicitly confirmed this proposed relationship.')
|
||||
};
|
||||
|
||||
const createEdgeOutputSchema = {
|
||||
@@ -229,7 +231,8 @@ const queryEdgesOutputSchema = {
|
||||
// rah_update_edge schemas
|
||||
const updateEdgeInputSchema = {
|
||||
id: z.number().int().positive().describe('Edge ID to update'),
|
||||
explanation: z.string().min(1).optional().describe('New explanation text (will re-infer relationship type)')
|
||||
explanation: z.string().min(1).describe('New explanation text (will re-infer relationship type)'),
|
||||
confirmed_by_user: z.boolean().describe('Must be true. Only update the edge after the user explicitly confirmed the corrected relationship.')
|
||||
};
|
||||
|
||||
const updateEdgeOutputSchema = {
|
||||
@@ -315,13 +318,34 @@ async function resolveBaseUrl() {
|
||||
if (envTarget && envTarget.trim().length > 0) {
|
||||
return envTarget.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const isReachableBaseUrl = async (candidate) => {
|
||||
if (!candidate) return false;
|
||||
const normalized = String(candidate).replace(/\/+$/, '');
|
||||
try {
|
||||
const response = await fetch(`${normalized}/api/contexts`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(1500)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const status = readStatusFile();
|
||||
if (status?.target_base_url) {
|
||||
return String(status.target_base_url).replace(/\/+$/, '');
|
||||
}
|
||||
if (status?.port) {
|
||||
return `http://127.0.0.1:${status.port}`.replace(/\/+$/, '');
|
||||
const candidates = [
|
||||
status?.target_base_url ? String(status.target_base_url) : null,
|
||||
process.env.NEXT_PUBLIC_BASE_URL || null,
|
||||
'http://127.0.0.1:3000'
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await isReachableBaseUrl(candidate)) {
|
||||
return String(candidate).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
return 'http://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
@@ -349,17 +373,16 @@ server.registerTool(
|
||||
'rah_add_node',
|
||||
{
|
||||
title: 'Add RA-H node',
|
||||
description: 'Create a new node in the local RA-H knowledge base. `context_id` is optional and should usually be omitted entirely unless one obvious existing context clearly fits.',
|
||||
description: 'Create a new node in the local RA-H knowledge base after you have already decided a net-new write is correct. If the user explicitly asked to save or import something and the target artifact is clear, write after duplicate/update checks. If you are only suggesting a save, propose the node first and wait for confirmation. Leave context blank by default. If the user explicitly wants context, use `context_name` rather than a numeric ID.',
|
||||
inputSchema: addNodeInputSchema,
|
||||
outputSchema: addNodeOutputSchema
|
||||
},
|
||||
async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => {
|
||||
async ({ title, content, source, link, description, context_name, metadata, chunk }) => {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
source: source?.trim() || content?.trim() || chunk?.trim() || undefined,
|
||||
link: link?.trim() || undefined,
|
||||
description: description?.trim() || undefined,
|
||||
context_id,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
metadata: metadata || {}
|
||||
};
|
||||
@@ -387,24 +410,21 @@ server.registerTool(
|
||||
'rah_search_nodes',
|
||||
{
|
||||
title: 'Search RA-H nodes',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
|
||||
description: 'Find existing RA-H entries that mention a topic before adding new ones. Leave context blank by default. If the user explicitly wants a context-specific lookup, use `context_name` rather than a numeric ID. For full current-turn grounding of a substantive request, prefer rah_retrieve_query_context.',
|
||||
inputSchema: searchNodesInputSchema,
|
||||
outputSchema: searchNodesOutputSchema
|
||||
},
|
||||
async ({ query, limit = 10, contextId }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', query.trim());
|
||||
params.set('limit', String(Math.min(Math.max(limit, 1), 25)));
|
||||
|
||||
if (contextId) {
|
||||
params.set('contextId', String(contextId));
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes?${params.toString()}`, {
|
||||
method: 'GET'
|
||||
async ({ query, limit = 10, context_name }) => {
|
||||
const result = await callRaHApi('/api/nodes/direct-search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query: query.trim(),
|
||||
limit: Math.min(Math.max(limit, 1), 50),
|
||||
context_name: typeof context_name === 'string' ? context_name.trim() : undefined,
|
||||
})
|
||||
});
|
||||
|
||||
const nodes = Array.isArray(result.data) ? result.data : [];
|
||||
const nodes = Array.isArray(result.data?.nodes) ? result.data.nodes : [];
|
||||
const summary =
|
||||
nodes.length === 0
|
||||
? 'No existing RA-H nodes mention that topic yet.'
|
||||
@@ -541,7 +561,7 @@ server.registerTool(
|
||||
'rah_update_node',
|
||||
{
|
||||
title: 'Update RA-H node',
|
||||
description: 'Update an existing node. `context_id` is optional and should usually be omitted entirely unless you are intentionally setting or clearing a real context.',
|
||||
description: 'Update an existing node when it is clearly the same artifact and a net-new node would be redundant. Explicit user-directed updates can proceed once the target node is clear. Context is preserved by default. If the user explicitly wants to change context, use `context_name`. Use `clear_context` only when the user explicitly wants to remove the node context.',
|
||||
inputSchema: updateNodeInputSchema,
|
||||
outputSchema: updateNodeOutputSchema
|
||||
},
|
||||
@@ -561,6 +581,10 @@ server.registerTool(
|
||||
}
|
||||
delete mappedUpdates.chunk;
|
||||
|
||||
if (mappedUpdates.context_name && mappedUpdates.clear_context) {
|
||||
throw new Error('context_name cannot be combined with clear_context: true.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/nodes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(mappedUpdates)
|
||||
@@ -624,17 +648,22 @@ server.registerTool(
|
||||
'rah_create_edge',
|
||||
{
|
||||
title: 'Create RA-H edge',
|
||||
description: 'Create a connection between two nodes.',
|
||||
description: 'Create a connection between two nodes only after the user has explicitly confirmed the proposed relationship.',
|
||||
inputSchema: createEdgeInputSchema,
|
||||
outputSchema: createEdgeOutputSchema
|
||||
},
|
||||
async ({ sourceId, targetId, explanation }) => {
|
||||
async ({ sourceId, targetId, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('rah_create_edge requires explicit user confirmation before writing the relationship.');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_node_id: sourceId,
|
||||
to_node_id: targetId,
|
||||
explanation: explanation.trim(),
|
||||
source: 'helper_name',
|
||||
created_via: 'mcp'
|
||||
created_via: 'mcp',
|
||||
confirmed_by_user: true
|
||||
};
|
||||
|
||||
const result = await callRaHApi('/api/edges', {
|
||||
@@ -692,19 +721,20 @@ server.registerTool(
|
||||
'rah_update_edge',
|
||||
{
|
||||
title: 'Update RA-H edge',
|
||||
description: 'Update an existing edge connection.',
|
||||
description: 'Update an existing edge connection only after the user explicitly confirmed the corrected relationship.',
|
||||
inputSchema: updateEdgeInputSchema,
|
||||
outputSchema: updateEdgeOutputSchema
|
||||
},
|
||||
async ({ id, explanation }) => {
|
||||
if (typeof explanation !== 'string' || explanation.trim().length === 0) {
|
||||
throw new Error('explanation is required');
|
||||
async ({ id, explanation, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('rah_update_edge requires explicit user confirmation before writing the corrected relationship.');
|
||||
}
|
||||
|
||||
const result = await callRaHApi(`/api/edges/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
context: { explanation: explanation.trim(), created_via: 'mcp' }
|
||||
context: { explanation: explanation.trim(), created_via: 'mcp' },
|
||||
confirmed_by_user: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -921,11 +951,11 @@ server.registerTool(
|
||||
'rah_write_context',
|
||||
{
|
||||
title: 'Write RA-H context node',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this sparingly for unusually valuable context. Never call it unless the user has clearly said yes.',
|
||||
description: 'Write one atomic durable context node to the graph only after the user has explicitly approved the save. Use this for agent-suggested capture after you already proposed the node briefly and got a clear yes. Prefer ordinary create/update flows for explicit user-directed capture.',
|
||||
inputSchema: writeContextInputSchema,
|
||||
outputSchema: writeContextOutputSchema
|
||||
},
|
||||
async ({ title, description, source, context_id, metadata, confirmed_by_user }) => {
|
||||
async ({ title, description, source, context_name, metadata, confirmed_by_user }) => {
|
||||
if (!confirmed_by_user) {
|
||||
throw new Error('rah_write_context requires explicit user confirmation before writing to the graph.');
|
||||
}
|
||||
@@ -936,7 +966,7 @@ server.registerTool(
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
source: source?.trim() || undefined,
|
||||
context_id: context_id ?? null,
|
||||
context_name: context_name?.trim() || undefined,
|
||||
metadata: {
|
||||
captured_by: 'human',
|
||||
captured_method: 'write_context',
|
||||
|
||||
Reference in New Issue
Block a user