Three things found by actually running the server over stdio against a live backend
instead of describing it.
1. recall ignored 'limit'. The backend treats it as a retrieval hint and returns
everything inside its own token budget — 113 facts, 43 KB of JSON, for a request
that asked for 2. The tool's own description ('Max results') was therefore false,
and every recall spent roughly 10k tokens of the caller's context. The MCP layer
now enforces the requested limit and reports 'N of M' when it truncated:
limit=2 goes from 43541 to 1000 chars.
2. serverInfo.version said 1.0.0 while package.json and server.json say 0.1.0. The
MCP registry validates the resolved package against the manifest, so a version
that disagrees with the tarball is a review failure.
3. The README claimed 'Claude Code, OpenClaw, Cursor, etc.' None of those was tested.
Replaced with what was measured — MCP 2025-06-18, initialize/tools/list/tools/call
round-trip, memory_recall returning real results over stdio — plus the Claude Code
config we actually run.
362 lines
13 KiB
JavaScript
362 lines
13 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 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',
|
||
// Keep in step with package.json and server.json — the MCP registry validates
|
||
// the package it resolves against this manifest, and a version that disagrees
|
||
// with the tarball is a review failure, not a cosmetic drift.
|
||
version: '0.1.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 all = (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,
|
||
}));
|
||
|
||
// The backend treats `limit` as a retrieval hint, not a result cap: it returns
|
||
// everything that fits its own token budget (~110 facts, ~40 KB of JSON) no
|
||
// matter what we ask for. Unenforced here, the tool's own description would be
|
||
// false and every recall would spend ~10k tokens of the caller's context.
|
||
const effectiveLimit = body.limit;
|
||
const memories = all.slice(0, effectiveLimit);
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
success: true,
|
||
bank_id: bankId,
|
||
count: memories.length,
|
||
...(all.length > memories.length
|
||
? { returned_of_available: `${memories.length} of ${all.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);
|
||
});
|