Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/mcp/in-memory-kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ export class InMemoryKV {
return entries ? (Array.from(entries.values()) as T[]) : [];
}

persist(): void {
if (!this.persistPath) return;
persist(): boolean {
if (!this.persistPath) return false;
try {
const dir = dirname(this.persistPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
Expand All @@ -52,10 +52,12 @@ export class InMemoryKV {
data[scope] = Object.fromEntries(entries);
}
writeFileSync(this.persistPath, JSON.stringify(data), "utf-8");
return true;
} catch (err) {
process.stderr.write(
`[@agentmemory/mcp] Persist failed: ${err instanceof Error ? err.message : String(err)}\n`,
);
return false;
}
}
}
17 changes: 14 additions & 3 deletions src/mcp/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,16 @@ async function handleProxy(
}
}

function localFallbackNotice(persisted: boolean) {
const store = persisted
? "the local fallback file, which the server does not read"
: "this process's in-memory fallback store, which is lost when the process exits";
return {
storage: "local",
warning: `agentmemory server at ${displayAgentmemoryUrl()} did not confirm this call, so it ran against ${store}.`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

async function handleLocal(
v: Validated,
kvInstance: InMemoryKV,
Expand All @@ -344,8 +354,8 @@ async function handleLocal(
...(v.project !== undefined && { project: v.project }),
...((v.agentId ?? getAgentId()) !== undefined && { agentId: v.agentId ?? getAgentId() }),
});
kvInstance.persist();
return textResponse({ saved: id });
const persisted = kvInstance.persist();
return textResponse({ saved: id, ...localFallbackNotice(persisted) });
}

case "memory_recall":
Expand Down Expand Up @@ -422,11 +432,12 @@ async function handleLocal(
deleted++;
}
}
kvInstance.persist();
const persisted = kvInstance.persist();
return textResponse({
deleted,
requested: (v.memoryIds || []).length,
reason: v.reason,
...localFallbackNotice(persisted),
});
}

Expand Down
3 changes: 2 additions & 1 deletion test/mcp-standalone-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@ describe("@agentmemory/mcp standalone — server proxy", () => {
return new Response("not found", { status: 404 });
});
try {
await handleToolCall("memory_save", { content: "force-proxy" });
const result = await handleToolCall("memory_save", { content: "force-proxy" });
expect(JSON.parse(result.content[0].text)).toEqual({ id: "m-1", action: "created" });
expect(calls.some((u) => u.endsWith("/agentmemory/livez"))).toBe(false);
expect(calls.some((u) => u.endsWith("/agentmemory/remember"))).toBe(true);
} finally {
Expand Down
92 changes: 92 additions & 0 deletions test/mcp-standalone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,98 @@ describe("handleToolCall", () => {
expect(parsed.deleted).toBe(1);
expect(parsed.requested).toBe(2);
});

describe("local fallback writes", () => {
const serverUrl = "http://127.0.0.1:3999";
const originalUrl = process.env["AGENTMEMORY_URL"];

beforeEach(() => {
process.env["AGENTMEMORY_URL"] = serverUrl;
});

afterEach(() => {
if (originalUrl === undefined) delete process.env["AGENTMEMORY_URL"];
else process.env["AGENTMEMORY_URL"] = originalUrl;
});

it("memory_save tells the caller the memory went to the local file, not the server", async () => {
const kv = new InMemoryKV("/tmp/test-fallback.json");
const result = await handleToolCall("memory_save", { content: "fallback save" }, kv);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.saved).toMatch(/^mem_/);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain(serverUrl);
expect(parsed.warning).not.toContain("/tmp/test-fallback.json");
expect(parsed.warning).not.toContain("lost");
expect(writeFileSync).toHaveBeenCalledTimes(1);
});

it("memory_save says the memory is lost on exit when there is no local file", async () => {
const kv = new InMemoryKV();
const parsed = JSON.parse(
(await handleToolCall("memory_save", { content: "no file" }, kv)).content[0].text,
);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain(serverUrl);
expect(parsed.warning).toContain("lost when");
});

it("memory_save says the memory is lost on exit when writing the local file failed", async () => {
vi.mocked(writeFileSync).mockImplementationOnce(() => {
throw new Error("EACCES");
});
const kv = new InMemoryKV("/tmp/test-fallback.json");
const parsed = JSON.parse(
(await handleToolCall("memory_save", { content: "unwritable" }, kv)).content[0].text,
);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain("lost when");
expect(writeFileSync).toHaveBeenCalledTimes(1);
});

it("memory_save marks the result local when the server stops answering mid-session", async () => {
setLivezProbe(async () => ({ ok: true, status: 200, statusText: "OK" }));
(globalThis as { fetch: typeof fetch }).fetch = vi.fn(async () => {
throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
}) as unknown as typeof fetch;
const kv = new InMemoryKV("/tmp/test-fallback.json");
const parsed = JSON.parse(
(await handleToolCall("memory_save", { content: "hung server" }, kv)).content[0].text,
);
expect(parsed.saved).toMatch(/^mem_/);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain(serverUrl);
});

it("memory_governance_delete marks the result local", async () => {
const kv = new InMemoryKV("/tmp/test-fallback.json");
const parsed = JSON.parse(
(await handleToolCall(
"memory_governance_delete",
{ memoryIds: ["mem_on_server"] },
kv,
)).content[0].text,
);
expect(parsed.deleted).toBe(0);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain(serverUrl);
expect(parsed.warning).not.toContain("lost");
expect(writeFileSync).toHaveBeenCalledTimes(1);
});

it("memory_governance_delete says the change is lost on exit when there is no local file", async () => {
const kv = new InMemoryKV();
const parsed = JSON.parse(
(await handleToolCall(
"memory_governance_delete",
{ memoryIds: ["mem_on_server"] },
kv,
)).content[0].text,
);
expect(parsed.storage).toBe("local");
expect(parsed.warning).toContain("lost when");
});
});
});

describe("initialize protocol version negotiation", () => {
Expand Down
Loading