fleet-memory/mcp-server/server.js
RCLL 45c1a3c633 chore: default bank rcll-main, announce it, move split tool out of the root
The default memory bank becomes 'rcll-main'. hindsight-mempalace-mcp@1.0.0 (published,
~145 downloads/month) defaulted to 'mempalace-main', so an install that never set the
variable and switches packages would open a different, empty bank. MEMPALACE_BANK is
still honoured, and when neither variable is set the server now prints which bank it
picked and how to keep reading the old one, instead of choosing silently.

rcll-split.py moves to scripts/ — it documents how the fork series was built, which
is not a root-level artifact of the product.
2026-08-24 08:28:28 +03:00

349 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 is 'rcll-main'. The pre-rebrand package
// hindsight-mempalace-mcp@1.0.0 defaulted to 'mempalace-main', so an install that
// never set the env var and switches packages would land in a different, empty bank.
// That reads as "the update erased my memory", so we say it out loud instead of
// guessing: the legacy env var is still honoured, and defaulting is announced.
const LEGACY_DEFAULT_BANK = 'mempalace-main';
const DEFAULT_BANK = envWithFallback(...LEGACY_ENV[1]) || 'rcll-main';
if (!process.env.RCLL_BANK && !process.env.MEMPALACE_BANK) {
console.error(
`rcll-mcp: RCLL_BANK is unset, using '${DEFAULT_BANK}'. ` +
`Coming from hindsight-mempalace-mcp? Your memory is in '${LEGACY_DEFAULT_BANK}' — ` +
`set RCLL_BANK=${LEGACY_DEFAULT_BANK} to keep reading it.`
);
}
// --- 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.01.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);
});