Recovered from the 2026-06-27 snapshot import by classifying the base..snapshot delta at line granularity. Upstream base: d054b884 (2026-04-10).
341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* RCLL — Standalone MCP Server
|
||
* (fork of vectorize-io/hindsight; self-hosted shared memory for a team of agents)
|
||
*
|
||
* Provides 5 memory tools over MCP (stdio transport):
|
||
* memory_retain — save memories with room/hall/layer classification
|
||
* memory_recall — scoped semantic search
|
||
* memory_reflect — deep reasoning over stored memories
|
||
* memory_compress — create closet summaries
|
||
* memory_bridge — cross-bank tunnels
|
||
*
|
||
* Connects to the RCLL backend (default: http://127.0.0.1:5100)
|
||
*
|
||
* Usage:
|
||
* RCLL_URL=http://localhost:5100 node server.js
|
||
*
|
||
* Claude Code config (~/.claude/mcp.json):
|
||
* {
|
||
* "mcpServers": {
|
||
* "rcll": {
|
||
* "command": "node",
|
||
* "args": ["/path/to/mcp-server/server.js"],
|
||
* "env": { "RCLL_URL": "http://localhost:5100" }
|
||
* }
|
||
* }
|
||
* }
|
||
*/
|
||
|
||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||
import { z } from 'zod';
|
||
|
||
// --- Config ---
|
||
|
||
// RCLL_* are the current names. The pre-rebrand names are still honored as a
|
||
// fallback so an existing install keeps working after `npm update` — each one
|
||
// hit prints a deprecation notice on stderr (stdout is the MCP transport).
|
||
const LEGACY_ENV = [
|
||
['RCLL_URL', 'HINDSIGHT_URL'],
|
||
['RCLL_BANK', 'MEMPALACE_BANK'],
|
||
];
|
||
|
||
function envWithFallback(current, legacy) {
|
||
if (process.env[current]) return process.env[current];
|
||
if (process.env[legacy]) {
|
||
console.error(`rcll-mcp: ${legacy} is deprecated — rename it to ${current}.`);
|
||
return process.env[legacy];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
const RCLL_URL = envWithFallback(...LEGACY_ENV[0]) || 'http://127.0.0.1:5100';
|
||
const RCLL_BASE = `${RCLL_URL}/v1/default/banks`;
|
||
// The default bank literal deliberately stays 'mempalace-main': that is the value
|
||
// hindsight-mempalace-mcp@1.0.0 shipped with, so anyone who never set the env var is
|
||
// already living in that bank. Renaming the default would silently drop them into an
|
||
// empty bank on upgrade and read as "the update erased my memory".
|
||
// Drop this at the 60-day mark, together with the legacy image alias.
|
||
const DEFAULT_BANK = envWithFallback(...LEGACY_ENV[1]) || 'mempalace-main';
|
||
|
||
// --- RCLL HTTP client ---
|
||
|
||
async function hindsightRequest(method, path, body = null) {
|
||
const url = `${RCLL_BASE}${path}`;
|
||
const opts = {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (body) opts.body = JSON.stringify(body);
|
||
|
||
const res = await fetch(url, opts);
|
||
const text = await res.text();
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch {
|
||
throw new Error(`RCLL backend returned non-JSON: ${text.slice(0, 200)}`);
|
||
}
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.detail || `RCLL API error ${res.status}: ${JSON.stringify(data)}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// --- MCP Server ---
|
||
|
||
const server = new McpServer({
|
||
name: 'rcll',
|
||
version: '1.0.0',
|
||
});
|
||
|
||
// Tool 1: memory_retain
|
||
server.tool(
|
||
'memory_retain',
|
||
'Save a fact, observation, or document to long-term memory with automatic room/hall classification. ' +
|
||
'Rooms: auth, pipeline, schema, infrastructure, ui, api, deployment, monitoring, agent, general. ' +
|
||
'Halls: fact, event, decision, preference, discovery, procedure, warning. ' +
|
||
'Layers: L0=Identity (always loaded), L1=Critical, L2=Session (default), L3=Deep.',
|
||
{
|
||
text: z.string().describe('The text to memorize — a fact, observation, or document content'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
context: z.string().optional().describe('Context label (e.g. "meeting notes", "client call")'),
|
||
document_id: z.string().optional().describe('Document ID to group related facts'),
|
||
tags: z.array(z.string()).optional().describe('Tags for categorization'),
|
||
room: z.string().optional().describe('Topic room (auto-classified if omitted)'),
|
||
hall: z.enum(['fact', 'event', 'decision', 'preference', 'discovery', 'procedure', 'warning']).optional().describe('Knowledge type (auto-classified if omitted)'),
|
||
layer: z.enum(['L0', 'L1', 'L2', 'L3']).optional().describe('Priority layer (default: L2)'),
|
||
},
|
||
async ({ text, bank_id, context, document_id, tags, room, hall, layer }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
const item = { content: text };
|
||
if (context) item.context = context;
|
||
if (document_id) item.document_id = document_id;
|
||
if (tags) item.tags = tags;
|
||
if (room) item.room = room;
|
||
if (hall) item.hall = hall;
|
||
if (layer) item.layer = layer;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/memories`, {
|
||
items: [item],
|
||
});
|
||
|
||
const storedIds = (result.items || []).map(i => i.id || i.uuid).filter(Boolean);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
items_stored: result.items_count || 1,
|
||
ids: storedIds.length ? storedIds : null,
|
||
room: room || 'auto',
|
||
hall: hall || 'auto',
|
||
layer: layer || 'L2',
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 2: memory_recall
|
||
server.tool(
|
||
'memory_recall',
|
||
'Search long-term memory for relevant facts. Uses semantic search with optional room/hall scoping ' +
|
||
'for significantly improved retrieval accuracy. Supports layer cascade (L0 results always prioritized).',
|
||
{
|
||
query: z.string().describe('What to search for in memory'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
limit: z.number().optional().describe('Max results (default: 10)'),
|
||
room: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by room(s) — applied before semantic search'),
|
||
hall: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by hall(s): fact, event, decision, etc.'),
|
||
max_layer: z.enum(['L0', 'L1', 'L2', 'L3']).optional().describe('Max layer depth to search (default: L3 = all)'),
|
||
},
|
||
async ({ query, bank_id, limit, room, hall, max_layer }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const body = {
|
||
query,
|
||
limit: limit || 10,
|
||
};
|
||
if (room) body.room = Array.isArray(room) ? room : [room];
|
||
if (hall) body.hall = Array.isArray(hall) ? hall : [hall];
|
||
if (max_layer) body.max_layer = max_layer;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/memories/recall`, body);
|
||
|
||
const memories = (result.results || []).map(r => ({
|
||
id: r.id || r.uuid || null,
|
||
text: r.text,
|
||
type: r.type,
|
||
entities: r.entities,
|
||
occurred: r.occurred_start || null,
|
||
room: r.room || null,
|
||
hall: r.hall || null,
|
||
}));
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
count: memories.length,
|
||
memories,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 3: memory_reflect
|
||
server.tool(
|
||
'memory_reflect',
|
||
'Deep reasoning over memory — synthesizes facts, finds patterns, answers complex questions with citations. ' +
|
||
'Use for analysis: "What patterns emerge from recent events?" or "Summarize everything about X."',
|
||
{
|
||
query: z.string().describe('Question to reason about over stored memories'),
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
},
|
||
async ({ query, bank_id }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/reflect`, { query });
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
answer: result.answer || result.response || result.text || JSON.stringify(result),
|
||
citations: result.based_on || result.citations || [],
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 4: memory_compress
|
||
server.tool(
|
||
'memory_compress',
|
||
'Create compressed memory summaries (closets) from stored facts. Groups memories by room+hall ' +
|
||
'and creates AI-generated summaries with source pointers. Use when a topic has accumulated many facts.',
|
||
{
|
||
bank_id: z.string().optional().describe(`Memory bank ID (default: ${DEFAULT_BANK})`),
|
||
room: z.string().optional().describe('Topic to compress (e.g. "auth", "pipeline")'),
|
||
hall: z.string().optional().describe('Knowledge type to compress (e.g. "fact", "decision")'),
|
||
min_sources: z.number().optional().describe('Min memories needed to create a closet (default: 5)'),
|
||
query: z.string().optional().describe('Query to guide compression focus'),
|
||
},
|
||
async ({ bank_id, room, hall, min_sources, query }) => {
|
||
const bankId = bank_id || DEFAULT_BANK;
|
||
|
||
const body = {};
|
||
if (room) body.room = room;
|
||
if (hall) body.hall = hall;
|
||
if (min_sources) body.min_sources = min_sources;
|
||
if (query) body.query = query;
|
||
|
||
const result = await hindsightRequest('POST', `/${bankId}/closets`, body);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
closets_created: result.closets_created || 0,
|
||
closets: result.closets || [],
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// Tool 5: memory_bridge
|
||
server.tool(
|
||
'memory_bridge',
|
||
'Create a cross-bank memory bridge (tunnel) between two related memories in different banks. ' +
|
||
'Relations: same_concept, depends_on, contradicts, extends. Use when concepts in separate banks are related.',
|
||
{
|
||
source_bank: z.string().describe('Source bank ID'),
|
||
source_memory: z.string().describe('UUID of the source memory'),
|
||
target_bank: z.string().describe('Target bank ID'),
|
||
target_memory: z.string().describe('UUID of the target memory'),
|
||
relation: z.enum(['same_concept', 'depends_on', 'contradicts', 'extends']).describe('Relationship type'),
|
||
confidence: z.number().min(0).max(1).optional().describe('Confidence score 0.0–1.0 (default: 0.8)'),
|
||
},
|
||
async ({ source_bank, source_memory, target_bank, target_memory, relation, confidence }) => {
|
||
const body = {
|
||
source_bank,
|
||
source_memory,
|
||
target_bank,
|
||
target_memory,
|
||
relation,
|
||
};
|
||
if (confidence !== undefined) body.confidence = confidence;
|
||
|
||
const result = await hindsightRequest('POST', `/${source_bank}/tunnels`, body);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
tunnel: result.tunnel || result,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// --- Resources ---
|
||
|
||
server.resource(
|
||
'memory-config',
|
||
'rcll://config',
|
||
async () => ({
|
||
contents: [{
|
||
uri: 'rcll://config',
|
||
mimeType: 'application/json',
|
||
text: JSON.stringify({
|
||
rcll_url: RCLL_URL,
|
||
hindsight_url: RCLL_URL, // legacy alias, drop after 60d
|
||
default_bank: DEFAULT_BANK,
|
||
rooms: ['auth', 'pipeline', 'schema', 'infrastructure', 'ui', 'api', 'deployment', 'monitoring', 'agent', 'general'],
|
||
halls: ['fact', 'event', 'decision', 'preference', 'discovery', 'procedure', 'warning'],
|
||
layers: {
|
||
L0: 'Identity — always loaded',
|
||
L1: 'Critical facts — per-space',
|
||
L2: 'Session context — per-conversation (default)',
|
||
L3: 'Deep memory — full search only',
|
||
},
|
||
}, null, 2),
|
||
}],
|
||
})
|
||
);
|
||
|
||
// --- Start ---
|
||
|
||
async function main() {
|
||
const transport = new StdioServerTransport();
|
||
await server.connect(transport);
|
||
if (!process.env.RCLL_BANK && !process.env.MEMPALACE_BANK) {
|
||
console.error(
|
||
`rcll-mcp: no bank configured — falling back to the pre-rebrand default '${DEFAULT_BANK}'. ` +
|
||
'Set RCLL_BANK explicitly; this fallback goes away in a future major.'
|
||
);
|
||
}
|
||
console.error(`rcll-mcp running (backend: ${RCLL_URL}, bank: ${DEFAULT_BANK})`);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('Fatal:', err);
|
||
process.exit(1);
|
||
});
|