Skip to content

memory: add tests for the data file being destroyed from outside the process #5539

Open

Description

@reservoir-cmyk

Which server?

memory (@modelcontextprotocol/server-memory)

Protocol era

Modern (2026-07-28)

MCP client

No response

The problem

For context: I've been reading the documentation, the implementation, and the existing tests side by side — this note is about a place where the three don't quite line up.

The memory server's existing tests pin the atomic save, the stale-lock handling, and save-failure behaviour well. What they don't pin is what happens when the data file itself is destroyed from outside the process between operations: an operator deleting it, a rotation truncating it, a directory appearing at its path, or the temp remnant of a killed run left beside it. The server's promises are "atomic save" and "never silently drop data"; the cases below pin how those promises behave under external destruction. All four pass against the current implementation — they fix behaviour that today rests on implication, not on a test.

Cases

  1. File deleted between operations — the next write recreates it. loadGraph maps ENOENT to an empty graph (src/memory/index.ts:502-510), so a vanished file is a fresh start and the write recreates it; only the new graph is read back.
  2. File truncated to zero bytes — reads as an empty graph and the server keeps working. Zero lines is the natural empty-graph case of loadGraph.
  3. A directory appears at the data path — reads and writes fail loudly with EISDIR instead of silently reporting an empty graph (which would let a later save bury real data). Only ENOENT maps to empty (index.ts:506-513). The EISDIR case in file-lock.test.ts:349 covers the lock-acquisition stage; the load stage was unpinned.
  4. An orphaned temp remnant from a killed run — a file matching saveGraph's pattern (<name>.<32 hex>.tmp) with corrupt content sitting beside the data file affects neither loads nor saves, and is left untouched. Saves write a fresh temp + rename (index.ts:596-599) and loads read only the exact data path.

Solution you have in mind (optional)

Proposed tests

For src/memory/__tests__/knowledge-graph.test.ts (or a new file in the same style), driving KnowledgeGraphManager against a temp directory:

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { KnowledgeGraphManager } from "../index.js";

describe("KnowledgeGraphManager against a file destroyed from outside", () => {
  let manager: KnowledgeGraphManager;
  let testFilePath: string;
  let testDir: string;

  beforeEach(async () => {
    testDir = await fs.mkdtemp(
      path.join(path.dirname(fileURLToPath(import.meta.url)), "overnight-cable-"),
    );
    testFilePath = path.join(testDir, "memory.jsonl");
    manager = new KnowledgeGraphManager(testFilePath);
  });

  afterEach(async () => {
    await fs.rm(testDir, { recursive: true, force: true });
  });

  it("recreates the file when it is deleted between operations", async () => {
    await manager.createEntities([
      { name: "Alice", entityType: "person", observations: [] },
    ]);
    await fs.unlink(testFilePath);
    const created = await manager.createEntities([
      { name: "Bob", entityType: "person", observations: [] },
    ]);
    expect(created).toHaveLength(1);
    const graph = await manager.readGraph();
    expect(graph.entities.map((e) => e.name)).toEqual(["Bob"]);
    const onDisk = await fs.readFile(testFilePath, "utf-8");
    expect(onDisk).toContain('"Bob"');
  });

  it("reads an externally truncated file as an empty graph and keeps working", async () => {
    await manager.createEntities([
      { name: "Alice", entityType: "person", observations: ["x"] },
    ]);
    await fs.writeFile(testFilePath, "");
    const graph = await manager.readGraph();
    expect(graph.entities).toEqual([]);
    expect(graph.relations).toEqual([]);
    await manager.createEntities([
      { name: "Carol", entityType: "person", observations: [] },
    ]);
    const after = await manager.readGraph();
    expect(after.entities.map((e) => e.name)).toEqual(["Carol"]);
  });

  it("fails loudly, not silently empty, when the data path is a directory", async () => {
    await manager.createEntities([
      { name: "Alice", entityType: "person", observations: [] },
    ]);
    await fs.unlink(testFilePath);
    await fs.mkdir(testFilePath);
    await expect(manager.readGraph()).rejects.toThrow(/EISDIR/);
    await expect(
      manager.createEntities([
        { name: "Bob", entityType: "person", observations: [] },
      ]),
    ).rejects.toThrow(/EISDIR/);
  });

  it("ignores a previous run's orphaned temp file beside the data file", async () => {
    await manager.createEntities([
      { name: "Alice", entityType: "person", observations: [] },
    ]);
    const stray = path.join(
      testDir,
      "memory.jsonl.0123456789abcdef0123456789abcdef.tmp",
    );
    await fs.writeFile(stray, '{"type":"entity","name":"Ghost",');
    const graph = await manager.readGraph();
    expect(graph.entities.map((e) => e.name)).toEqual(["Alice"]);
    await manager.createEntities([
      { name: "Bob", entityType: "person", observations: [] },
    ]);
    const after = await manager.readGraph();
    expect(after.entities.map((e) => e.name)).toEqual(["Alice", "Bob"]);
    expect(await fs.readFile(stray, "utf-8")).toContain("Ghost");
  });
});

Which MCP feature does it demonstrate? (optional)

No response

Alternatives or workarounds you have tried

No workaround covers this — the behaviour is currently only implied, not pinned by a test. Related issues checked during duplicate search: #4885 and #4827 — near hits, not duplicates.

Already built it locally?

Verification

I ran these against a byte-identical copy of v2/main @ 6cfff62 (Node 24.22.1, vitest 4.1.12): 4 passed. Full memory suite with these included: 18 files, 241 passed + 1 skipped. No behaviour change is proposed; if any case's expectation is not the intended contract, the test is the place to say so.

Prompt used

Per this repository's contribution practice, the prompt that produced these tests: "In src/memory, add tests to the knowledge-graph suite for the data file being destroyed from OUTSIDE the process between operations: (1) the file is deleted — the next write recreates it and reads back only the new graph; (2) the file is truncated to zero bytes — it reads as an empty graph and the server keeps working; (3) a directory appears at the data path — reads and writes fail with EISDIR rather than reporting an empty graph; (4) an orphaned temp file in saveGraph's naming pattern (<name>.<32 hex>.tmp) sits beside the data file — loads and saves are unaffected and the remnant is left untouched. Drive everything through KnowledgeGraphManager against a temp directory, in the style of tests/knowledge-graph.test.ts."

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is a request for this repository or one of its servers, not a new server, the MCP specification or an SDK.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions