import { afterAll, beforeAll, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseReferenceGraph, type ReferenceGraph } from "../../src/core/portability"; import { importReferenceMemory, openLocalStore, referenceMemoryCandidates, } from "../../src/runtime/bun/mcp-stdio"; import { createSqliteDb, openDatabase } from "../../src/runtime/bun/sqlite"; import { provisionWith } from "./harness"; /** * Drop-in substitution for `@modelcontextprotocol/server-memory`. * * The claim being tested is that a user switches by editing one line of MCP * configuration: the same nine tool names, the same arguments, the same * response shapes, and their existing `memory.json` still there afterwards. * So every assertion here is written from the client's side of the wire, and * the fixture is that server's own on-disk format rather than a Titen export. */ const directory = mkdtempSync(join(tmpdir(), "titen-mcp-compat-")); const memoryFile = join(directory, "memory.json"); const dbPath = join(directory, "memory.db"); /** Exactly what the reference server writes: one JSON object per line. */ const FIXTURE_LINES = [ { type: "entity", name: "billing-service", entityType: "service", observations: [ "Handles refund processing and invoice generation for enterprise customers", "Owned by the payments team", ], }, { type: "entity", name: "Ada Lovelace", entityType: "person", observations: ["Leads the payments team", "Prefers asynchronous design reviews"], }, { type: "entity", name: "checkout-api", entityType: "service", observations: ["Calls billing-service when an order is cancelled"], }, { type: "entity", name: "quiet-worker", entityType: "service", observations: [] }, { type: "relation", from: "Ada Lovelace", to: "billing-service", relationType: "maintains" }, { type: "relation", from: "checkout-api", to: "billing-service", relationType: "depends_on" }, ]; const FIXTURE: ReferenceGraph = parseReferenceGraph( FIXTURE_LINES.map((line) => JSON.stringify(line)).join("\n"), ); /** * The reference server's entire search: lower-case substring containment over a * graph re-read from disk. Reimplemented here so the comparison in the search * test is against the real thing rather than against a description of it. */ function referenceSearch(graph: ReferenceGraph, query: string): ReferenceGraph { const entities = graph.entities.filter((entity) => entity.name.toLowerCase().includes(query.toLowerCase()) || entity.entityType.toLowerCase().includes(query.toLowerCase()) || entity.observations.some((observation) => observation.toLowerCase().includes(query.toLowerCase()))); const names = new Set(entities.map((entity) => entity.name)); return { entities, relations: graph.relations.filter((relation) => names.has(relation.from) || names.has(relation.to)), }; } let local: Awaited>; let restrictedKey: string; let messageId = 0; interface ToolReply { isError?: boolean; content: { type: string; text: string }[]; structuredContent: any; } async function rpc(method: string, params: unknown, key?: string) { const response = await local.app(new Request("http://127.0.0.1/mcp", { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${key ?? local.apiKey}`, "content-type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", id: (messageId += 1), method, params }), })); return await response.json() as { result?: any; error?: { code: number; message: string } }; } async function callTool(name: string, args: unknown = {}, key?: string): Promise { const reply = await rpc("tools/call", { name, arguments: args }, key); assert.equal(reply.error, undefined, `${name} returned a JSON-RPC error`); return reply.result as ToolReply; } /** Every reference tool reports its result twice; a client may read either. */ function toolResult(reply: ToolReply): any { assert.equal(reply.content[0]!.type, "text"); return JSON.parse(reply.content[0]!.text); } const readGraph = async (): Promise => (await callTool("read_graph")).structuredContent; beforeAll(async () => { writeFileSync(memoryFile, `${FIXTURE_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`); local = await openLocalStore(dbPath); const database = createSqliteDb(openDatabase(dbPath)); const [organization] = await database.all<{ id: string }>(`SELECT id FROM organizations`); const provisioned = await provisionWith(database, { orgId: organization!.id, scopes: ["mcp:call"], }); restrictedKey = provisioned.key; }); afterAll(() => { local.close(); rmSync(directory, { recursive: true, force: true }); }); test("the search covers where the reference server actually writes", () => { const previous = process.env.MEMORY_FILE_PATH; delete process.env.MEMORY_FILE_PATH; try { const candidates = referenceMemoryCandidates("/work"); const pkg = "node_modules/@modelcontextprotocol/server-memory/dist"; // The cwd is a guess — an MCP server launched by a desktop client inherits // the client's directory — so it must not be the only place we look. assert.ok(candidates.includes("/work/memory.jsonl")); assert.ok(candidates.includes("/work/memory.json")); // Where the reference server writes with MEMORY_FILE_PATH unset: beside its // own module. Missing this is what made a switch import zero and say nothing. assert.ok(candidates.includes(`/work/${pkg}/memory.jsonl`)); assert.ok(candidates.includes(`/work/${pkg}/memory.json`)); // Explicit configuration still wins outright, relative or absolute. process.env.MEMORY_FILE_PATH = "graph.jsonl"; assert.deepEqual(referenceMemoryCandidates("/work"), ["/work/graph.jsonl"]); process.env.MEMORY_FILE_PATH = "/elsewhere/graph.jsonl"; assert.deepEqual(referenceMemoryCandidates("/work"), ["/elsewhere/graph.jsonl"]); } finally { if (previous === undefined) delete process.env.MEMORY_FILE_PATH; else process.env.MEMORY_FILE_PATH = previous; } }); test("an existing memory.json is imported without data loss", async () => { const imported = await importReferenceMemory(local, memoryFile, `${dbPath}.imported`); assert.deepEqual(imported, { entities: 4, relations: 2 }); // Byte-for-byte the same graph: names, types, observation text and order. assert.deepEqual(await readGraph(), FIXTURE); // A second start must not re-import: that would resurrect deleted entities. assert.equal( await importReferenceMemory(local, memoryFile, `${dbPath}.imported`), undefined, ); assert.deepEqual(await readGraph(), FIXTURE); }); test("the installed entry point adopts a memory.json in the working directory", async () => { const home = join(directory, "adopt-home"); const work = join(directory, "adopt-work"); mkdirSync(home); mkdirSync(work); writeFileSync(join(work, "memory.json"), [ JSON.stringify({ type: "entity", name: "billing-service", entityType: "service", observations: ["Handles refunds"], }), JSON.stringify({ type: "relation", from: "checkout-api", to: "billing-service", relationType: "depends_on" }), ].join("\n")); const child = Bun.spawn({ cmd: ["bun", join(import.meta.dir, "../../src/runtime/bun/cli.ts"), "mcp"], cwd: work, // Deliberately not `...process.env`: this is the no-configuration case. env: { PATH: process.env.PATH ?? "", HOME: home }, stdin: "pipe", stdout: "pipe", stderr: "pipe", }); child.stdin.write([ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "compat", version: "1" } } }, { jsonrpc: "2.0", method: "notifications/initialized" }, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "read_graph", arguments: {} } }, ].map((message) => `${JSON.stringify(message)}\n`).join("")); child.stdin.end(); const [exitCode, stdout, stderr] = await Promise.all([ Promise.race([child.exited, Bun.sleep(30_000).then(() => -1)]), new Response(child.stdout).text(), new Response(child.stderr).text(), ]); assert.equal(exitCode, 0, stderr); // Progress belongs on stderr; stdout is the MCP transport and must stay JSON. assert.match(stderr, /imported 1 entities and 1 relations/); const replies = stdout.split("\n").filter(Boolean).map((line) => JSON.parse(line)); assert.deepEqual(replies.at(-1).result.structuredContent, { entities: [{ name: "billing-service", entityType: "service", observations: ["Handles refunds"] }], relations: [{ from: "checkout-api", to: "billing-service", relationType: "depends_on" }], }); }, 60_000); test("the graph is readable as a resource, the way the reference server serves it", async () => { // A client that reads memory://knowledge-graph instead of calling read_graph // used to get -32601 on the switch, which is a broken drop-in however well // the tool names line up. const listed = (await rpc("resources/list", {})).result.resources as { uri: string; mimeType: string; }[]; const graphResource = listed.find((r) => r.uri === "memory://knowledge-graph"); assert.ok(graphResource, "memory://knowledge-graph must be listed"); assert.equal(graphResource.mimeType, "application/json"); const read = (await rpc("resources/read", { uri: "memory://knowledge-graph" })).result; const contents = read.contents as { uri: string; mimeType: string; text: string }[]; assert.equal(contents.length, 1); assert.equal(contents[0]!.uri, "memory://knowledge-graph"); // Same bytes the read_graph tool returns, so the two paths cannot drift. assert.deepEqual(JSON.parse(contents[0]!.text), await readGraph()); const unknown = await rpc("resources/read", { uri: "memory://nope" }); assert.ok(unknown.error, "an unknown resource must return an error"); assert.equal(unknown.error.code, -32602, "an unknown uri is a params error, not a 404"); }); test("the nine reference tool names are served with the reference schemas", async () => { const listed = (await rpc("tools/list", {})).result.tools as { name: string; inputSchema: { properties: Record; required: string[] }; annotations: Record; }[]; const byName = new Map(listed.map((tool) => [tool.name, tool])); const expected: Record = { create_entities: ["entities"], create_relations: ["relations"], add_observations: ["observations"], delete_entities: ["entityNames"], delete_observations: ["deletions"], delete_relations: ["relations"], read_graph: [], search_nodes: ["query"], open_nodes: ["names"], }; for (const [name, required] of Object.entries(expected)) { const tool = byName.get(name); assert.ok(tool, `${name} must be served`); assert.deepEqual(tool.inputSchema.required, required, `${name} argument names`); assert.deepEqual(Object.keys(tool.inputSchema.properties), required, `${name} properties`); } assert.equal(byName.get("search_nodes")!.annotations.readOnlyHint, true); assert.equal(byName.get("delete_entities")!.annotations.destructiveHint, true); // The Titen tools stay where they were; this is an addition, not a swap. assert.ok(byName.has("titen_remember")); }); test("search_nodes finds what the reference server's substring scan cannot", async () => { const query = "which service issues refunds to a customer"; // The incumbent lower-cases the whole query and asks `String.includes`, so a // question phrased as a question matches nothing at all. assert.deepEqual(referenceSearch(FIXTURE, query).entities, []); const found: ReferenceGraph = (await callTool("search_nodes", { query })).structuredContent; assert.equal( found.entities[0]?.name, "billing-service", "ranked retrieval must surface the refund-handling service first", ); assert.deepEqual( found.entities[0]!.observations, FIXTURE.entities[0]!.observations, "a hit carries the whole entity, as the reference shape requires", ); // Relations reach outside the matched set, exactly as the reference does. assert.ok(found.relations.some((relation) => relation.to === "billing-service")); // An unrelated question must not drag the whole graph back. const unrelated: ReferenceGraph = (await callTool("search_nodes", { query: "kubernetes cluster autoscaling" })).structuredContent; assert.deepEqual(unrelated.entities, []); }); test("create, add, and open behave as the reference server documents them", async () => { const created = await callTool("create_entities", { entities: [ { name: "payments-team", entityType: "team", observations: ["Owns billing-service"] }, { name: "billing-service", entityType: "service", observations: ["ignored duplicate"] }, ], }); assert.deepEqual(created.structuredContent.entities.map((entity: any) => entity.name), [ "payments-team", ], "an existing name is skipped and never returned"); assert.deepEqual(toolResult(created), created.structuredContent.entities); const relations = await callTool("create_relations", { relations: [ { from: "payments-team", to: "billing-service", relationType: "owns" }, { from: "Ada Lovelace", to: "billing-service", relationType: "maintains" }, ], }); assert.deepEqual(relations.structuredContent.relations, [ { from: "payments-team", to: "billing-service", relationType: "owns" }, ], "an existing triple is skipped"); const added = await callTool("add_observations", { observations: [{ entityName: "billing-service", contents: ["Owned by the payments team", "Runs on the shared Bun runtime"], }], }); assert.deepEqual(added.structuredContent, { results: [{ entityName: "billing-service", addedObservations: ["Runs on the shared Bun runtime"], }], }, "an observation already present is not added twice"); const missing = await callTool("add_observations", { observations: [{ entityName: "no-such-entity", contents: ["x"] }], }); assert.equal(missing.isError, true); assert.match(missing.content[0]!.text, /Entity with name no-such-entity not found/); const opened: ReferenceGraph = (await callTool("open_nodes", { names: ["payments-team", "no-such-entity"] })).structuredContent; assert.deepEqual(opened.entities.map((entity) => entity.name), ["payments-team"]); assert.deepEqual(opened.relations, [ { from: "payments-team", to: "billing-service", relationType: "owns" }, ]); }); test("deleting requires the purge capability and then really removes the record", async () => { const seeded = await callTool("create_entities", { entities: [{ name: "temp-entity", entityType: "service", observations: ["short lived"] }], }, restrictedKey); assert.notEqual(seeded.isError, true, "writing needs no new capability"); const refused = await callTool("delete_entities", { entityNames: ["temp-entity"] }, restrictedKey); assert.equal(refused.isError, true, "purge must fail closed without its scope"); assert.match(refused.content[0]!.text, /observations:purge/); // The restricted principal's own graph is untouched by the refusal. const stillThere = (await callTool("read_graph", {}, restrictedKey)).structuredContent; assert.deepEqual(stillThere.entities.map((entity: any) => entity.name), ["temp-entity"]); const deleted = await callTool("delete_observations", { deletions: [{ entityName: "billing-service", observations: ["Runs on the shared Bun runtime"] }], }); assert.deepEqual(deleted.structuredContent, { success: true, message: "Observations deleted successfully", }); assert.equal(deleted.content[0]!.text, "Observations deleted successfully"); await callTool("delete_relations", { relations: [{ from: "checkout-api", to: "billing-service", relationType: "depends_on" }], }); const afterRelation = await readGraph(); assert.ok(!afterRelation.relations.some((relation) => relation.relationType === "depends_on")); const removed = await callTool("delete_entities", { entityNames: ["checkout-api"] }); assert.deepEqual(removed.structuredContent, { success: true, message: "Entities deleted successfully", }); const graph = await readGraph(); assert.ok(!graph.entities.some((entity) => entity.name === "checkout-api")); assert.deepEqual( graph.entities.find((entity) => entity.name === "billing-service")!.observations, [ "Handles refund processing and invoice generation for enterprise customers", "Owned by the payments team", ], "only the deleted observation is gone", ); // Purged evidence must also leave retrieval, not just the graph read. const found: ReferenceGraph = (await callTool("search_nodes", { query: "order cancelled calls" })).structuredContent; assert.ok(!found.entities.some((entity) => entity.name === "checkout-api")); // A deleted name is free again: the row is gone, not merely hidden. const recreated = await callTool("create_entities", { entities: [{ name: "checkout-api", entityType: "service", observations: ["rebuilt"] }], }); assert.deepEqual(recreated.structuredContent.entities.map((entity: any) => entity.name), [ "checkout-api", ]); }); test("Windows line endings are stored as LF, not rejected as control characters", async () => { const created = await callTool("create_entities", { entities: [{ name: "windows-runner", entityType: "service", observations: ["Readme line\r\nnext"] }], }); assert.equal(created.isError, undefined); assert.deepEqual(created.structuredContent.entities[0].observations, ["Readme line\nnext"]); const added = await callTool("add_observations", { observations: [{ entityName: "windows-runner", contents: ["Deploys from\r\nthe release branch"] }], }); assert.equal(added.isError, undefined); assert.deepEqual(added.structuredContent.results[0].addedObservations, ["Deploys from\nthe release branch"]); const lone = await callTool("add_observations", { observations: [{ entityName: "windows-runner", contents: ["progress 10%\rprogress 99%"] }], }); assert.equal(lone.isError, true); assert.match(lone.content[0]!.text, /carriage return/); });