Files
self-directed-learning/test/test-mcp-shim.js
T
Krystie dc5dc94d79 Initial commit: Adaptive Recall sidecar for UMP (Phase 5)
Multi-channel retrieval sidecar over Universal Memory Protocol:
- 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph)
- ACT-R re-ranking (Anderson 1983) with access tracking
- Co-occurrence graph edges (Phase 6) for dense traversal
- Memory lifecycle decay (Phase 4) with per-kind confidence
- MCP shim routes recall through sidecar, falls back to canonical UMP

Architecture:
- src/server.js      HTTP sidecar on port 4380
- src/graph.js       2592-node / 111-edge graph from UMP (or +cooccur: 13k+)
- src/actr.js        A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf
- src/access_log.js  per-URN counter + last_accessed_at
- src/ump-recall-mcp.js  MCP shim (recall via sidecar, others passthrough)

Eval results (851-record UMP corpus):
- 2ch RRF over baseline: +50pp recall@10
- 3ch RRF (+graph): +60pp, 12 unique wins
- ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention

Tests: 76/76 passing across graph (27), actr (27), access_log (28),
decay (20), mcp-shim (sidecar + fallback). Run with: npm test

Inspired by AIAppsAPI/adaptive-recall but built from scratch against
existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333,
Ollama at :11434). No paid SaaS, MIT-licensed.
2026-07-12 19:24:47 -07:00

209 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
// test-mcp-shim.js — End-to-end smoke test for the ump-recall-mcp shim.
//
// Spawns the shim as a subprocess twice:
// Scenario A: sidecar reachable → expect recall._meta.source === "sidecar"
// Scenario B: sidecar unreachable → expect recall._meta.source === "ump-fallback"
//
// Sends JSON-RPC 2.0 over stdio (newline-delimited), one object per line.
// Asserts tools/list returns 7 tools and the recall hit has a non-empty urn.
import { spawn } from "node:child_process";
import process from "node:process";
import path from "node:path";
import url from "node:url";
const SHIM = path.resolve(
path.dirname(url.fileURLToPath(import.meta.url)),
"..",
"src",
"ump-recall-mcp.js",
);
const PROTO_VERSION = "2024-11-05";
const results = { A: null, B: null };
function startShim(env = {}) {
const child = spawn("node", [SHIM], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...env },
});
let buf = "";
const pending = new Map();
let nextId = 1;
child.stdout.on("data", (chunk) => {
buf += chunk.toString();
let nl;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id != null && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
else resolve(msg.result);
}
} catch {
// ignore non-JSON (e.g. progress noise)
}
}
});
function call(method, params) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
const timer = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`timeout on ${method}`));
}
}, 20000);
try {
child.stdin.write(
JSON.stringify({ jsonrpc: "2.0", id, method, params: params || {} }) + "\n",
);
} catch (e) {
clearTimeout(timer);
pending.delete(id);
reject(e);
}
// Resolve but we want to clear the timer too — wrap:
const orig = resolve;
// (no-op; promise resolves on the data handler which clears via closure)
}).then(
(v) => v,
(e) => { throw e; },
);
}
function kill() {
try { child.kill("SIGTERM"); } catch {}
setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 500);
}
return { child, call, kill };
}
function unwrapContent(result) {
// MCP tools/call returns { content: [{ type: "text", text: "<json string>" }] }
// (the SDK rejects type:"json"; we emit text+JSON.stringify on the shim side).
if (result && Array.isArray(result.content)) {
for (const c of result.content) {
if (c.type === "text" && c.text) {
try { return JSON.parse(c.text); } catch { return { text: c.text }; }
}
if (c.type === "json" && c.json) return c.json; // tolerate old shim shape
}
}
return result;
}
async function runScenario(name, env) {
const label = name === "A" ? "sidecar route" : "fallback route";
console.log(`\n=== Scenario ${name}: ${label} ===`);
const shim = startShim(env);
let pass = true;
const fails = [];
function check(cond, msg) {
if (!cond) { pass = false; fails.push(msg); console.log(`${msg}`); }
else { console.log(`${msg}`); }
}
try {
// 1. initialize
const initRes = await shim.call("initialize", {
protocolVersion: PROTO_VERSION,
capabilities: {},
clientInfo: { name: "test-mcp-shim", version: "0.0.1" },
});
check(
initRes && initRes.protocolVersion && initRes.serverInfo && initRes.serverInfo.name,
`initialize ok (server: ${initRes?.serverInfo?.name} v${initRes?.serverInfo?.version})`,
);
// 2. tools/list — assert 7 tools
const toolsRes = await shim.call("tools/list", {});
const tools = toolsRes?.tools || [];
check(Array.isArray(tools), "tools/list returns array");
check(tools.length === 7, `tools/list returns 7 tools (got ${tools.length})`);
const expected = ["recall", "remember", "get", "revise", "forget", "feedback", "capabilities"];
for (const t of expected) {
check(tools.some((x) => x.name === t), `tool present: ${t}`);
}
// 3. tools/call recall
const callRes = await shim.call("tools/call", {
name: "recall",
arguments: { query: "Triangles test", limit: 3 },
});
const payload = unwrapContent(callRes);
// 4. assertions
if (name === "A") {
check(
payload?._meta?.source === "sidecar",
`_meta.source === "sidecar" (got "${payload?._meta?.source}")`,
);
} else {
check(
payload?._meta?.source === "ump-fallback",
`_meta.source === "ump-fallback" (got "${payload?._meta?.source}")`,
);
}
// 5. at least 1 hit, first hit has non-empty urn
const hits = payload?.hits || payload?.results || payload?.memories || [];
check(Array.isArray(hits) && hits.length >= 1, `at least 1 hit (got ${hits.length})`);
if (hits.length >= 1) {
const first = hits[0];
// sidecar shape: { urn, ... } ; canonical UMP shape: { record: { id, ... } }
const urn = first.urn
|| first.id
|| first.memory_id
|| first.record?.id
|| first.record?.urn;
check(typeof urn === "string" && urn.length > 0, `first hit has non-empty urn (urn="${urn}")`);
}
} catch (e) {
pass = false;
fails.push(`exception: ${e.message}`);
console.log(` ✗ exception: ${e.message}`);
} finally {
shim.kill();
}
results[name] = { pass, fails };
}
(async () => {
// Scenario A: sidecar reachable on :4380
await runScenario("A", {
SIDECAR_URL: "http://127.0.0.1:4380",
UMP_DIR: "/root/.openclaw/agents/main/workspace/state/ump-local",
UMP_STORE: "json",
});
// Scenario B: sidecar unreachable, bad port
await runScenario("B", {
SIDECAR_URL: "http://127.0.0.1:9999",
FALLBACK_UMP_URL: "http://127.0.0.1:4317",
UMP_DIR: "/root/.openclaw/agents/main/workspace/state/ump-local",
UMP_STORE: "json",
});
console.log("\n=== Summary ===");
console.log(` Scenario A (sidecar route): ${results.A.pass ? "PASS" : "FAIL"}`);
if (!results.A.pass) for (const f of results.A.fails) console.log(` - ${f}`);
console.log(` Scenario B (fallback route): ${results.B.pass ? "PASS" : "FAIL"}`);
if (!results.B.pass) for (const f of results.B.fails) console.log(` - ${f}`);
const allPass = results.A.pass && results.B.pass;
process.exit(allPass ? 0 : 1);
})();