import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { handleToolCall } from "../src/mcp/standalone.js"; import { resetHandleForTests } from "../src/mcp/rest-proxy.js"; import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; type FetchMock = ReturnType; function installFetch(handler: (url: string, init?: RequestInit) => Response): FetchMock { const fn = vi.fn(async (url: string | URL, init?: RequestInit) => handler(url.toString(), init), ); (globalThis as { fetch: typeof fetch }).fetch = fn as unknown as typeof fetch; return fn; } const BASE = "http://localhost:3111"; describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const originalFetch = globalThis.fetch; beforeEach(() => { resetHandleForTests(); process.env["AGENTMEMORY_URL"] = BASE; delete process.env["AGENTMEMORY_SECRET"]; }); afterEach(() => { resetHandleForTests(); globalThis.fetch = originalFetch; delete process.env["AGENTMEMORY_URL"]; }); it("proxies memory_sessions to GET /agentmemory/sessions when server is up", async () => { const calls: Array<{ url: string; method: string }> = []; installFetch((url, init) => { calls.push({ url, method: init?.method || "GET" }); if (url.endsWith("/agentmemory/livez")) { return new Response("ok", { status: 200 }); } if (url.includes("/agentmemory/sessions")) { return new Response( JSON.stringify({ sessions: [{ id: "sess-1", observations: 69 }] }), { status: 200, headers: { "content-type": "application/json" } }, ); } return new Response("not found", { status: 404 }); }); const res = await handleToolCall("memory_sessions", { limit: 5 }); const body = JSON.parse(res.content[0].text); expect(body.sessions).toHaveLength(1); expect(body.sessions[0].id).toBe("sess-1"); expect(calls.find((c) => c.url.includes("/sessions"))).toBeDefined(); }); it("proxies memory_smart_search to POST /agentmemory/smart-search", async () => { installFetch((url, init) => { if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); if (url.endsWith("/agentmemory/smart-search")) { const body = JSON.parse((init?.body as string) || "{}"); return new Response( JSON.stringify({ mode: "compact", query: body.query, results: [{ id: "m1", score: 0.9 }], }), { status: 200 }, ); } return new Response("", { status: 404 }); }); const res = await handleToolCall("memory_smart_search", { query: "auth bug", limit: 5 }); const body = JSON.parse(res.content[0].text); expect(body.query).toBe("auth bug"); expect(body.results[0].id).toBe("m1"); }); it("proxies memory_recall to POST /agentmemory/search and forwards format/token_budget (#507)", async () => { const calls: Array<{ url: string; body?: unknown }> = []; installFetch((url, init) => { if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); const body = init?.body ? JSON.parse(init.body as string) : undefined; calls.push({ url, body }); if (url.endsWith("/agentmemory/search")) { return new Response( JSON.stringify({ mode: "full", facts: [{ id: "m1" }], narrative: "n", concepts: ["c"], files: ["f"], }), { status: 200, headers: { "content-type": "application/json" } }, ); } return new Response("not found", { status: 404 }); }); const res = await handleToolCall("memory_recall", { query: "auth bug", limit: 5, format: "full", token_budget: 800, }); const body = JSON.parse(res.content[0].text); expect(body.mode).toBe("full"); expect(body.facts[0].id).toBe("m1"); const searchCall = calls.find((c) => c.url.endsWith("/agentmemory/search")); expect(searchCall).toBeDefined(); expect(searchCall?.body).toEqual({ query: "auth bug", limit: 5, format: "full", token_budget: 800, }); expect(calls.find((c) => c.url.endsWith("/agentmemory/smart-search"))).toBeUndefined(); }); it("memory_recall defaults format to 'full' when omitted (#507)", async () => { let recallBody: Record | undefined; installFetch((url, init) => { if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); if (url.endsWith("/agentmemory/search")) { recallBody = init?.body ? JSON.parse(init.body as string) : undefined; return new Response(JSON.stringify({ mode: "full", facts: [] }), { status: 200 }); } return new Response("not found", { status: 404 }); }); await handleToolCall("memory_recall", { query: "x" }); expect(recallBody?.["format"]).toBe("full"); expect(recallBody).not.toHaveProperty("token_budget"); }); it("proxies memory_governance_delete to the DELETE REST endpoint", async () => { const calls: Array<{ url: string; method: string; body?: unknown }> = []; installFetch((url, init) => { const method = init?.method || "GET"; if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : undefined, }); if (url.endsWith("/agentmemory/governance/memories") && method === "DELETE") { return new Response(JSON.stringify({ success: true, deleted: 2 }), { status: 200, headers: { "content-type": "application/json" }, }); } return new Response("method not allowed", { status: 405, statusText: "Method Not Allowed" }); }); const res = await handleToolCall("memory_governance_delete", { memoryIds: "mem_1, mem_2", reason: "cleanup stale test data", }); expect(JSON.parse(res.content[0].text)).toEqual({ success: true, deleted: 2 }); expect(calls).toEqual([ { url: `${BASE}/agentmemory/governance/memories`, method: "DELETE", body: { memoryIds: ["mem_1", "mem_2"], reason: "cleanup stale test data", }, }, ]); }); it("local fallback returns the same shape as proxy for memory_smart_search", async () => { installFetch(() => { throw new Error("ECONNREFUSED"); }); const localKv = new InMemoryKV(undefined); await handleToolCall("memory_save", { content: "shape-check entry" }, localKv); const res = await handleToolCall("memory_smart_search", { query: "shape" }, localKv); const body = JSON.parse(res.content[0].text); expect(body).toHaveProperty("mode", "compact"); expect(Array.isArray(body.results)).toBe(true); expect(body.results[0].content).toBe("shape-check entry"); }); it("attaches Bearer token on the proxied tool request, not just the probe", async () => { process.env["AGENTMEMORY_SECRET"] = "s3cret"; const authByPath = new Map(); installFetch((url, init) => { const auth = (init?.headers as Record | undefined)?.[ "authorization" ]; const u = new URL(url); authByPath.set(u.pathname, auth); if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); return new Response(JSON.stringify({ sessions: [] }), { status: 200 }); }); await handleToolCall("memory_sessions", {}); expect(authByPath.get("/agentmemory/livez")).toBe("Bearer s3cret"); expect(authByPath.get("/agentmemory/sessions")).toBe("Bearer s3cret"); }); it("falls back to local InMemoryKV when server is unreachable", async () => { installFetch(() => { throw new Error("ECONNREFUSED"); }); const localKv = new InMemoryKV(undefined); await handleToolCall("memory_save", { content: "local only" }, localKv); const recall = await handleToolCall("memory_recall", { query: "local" }, localKv); const out = JSON.parse(recall.content[0].text); expect(out.mode).toBe("compact"); expect(out.results).toHaveLength(1); expect(out.results[0].content).toBe("local only"); }); it("invalidates the handle on proxy failure, so the next call re-probes", async () => { let probeCount = 0; let serverUp = true; installFetch((url) => { if (url.endsWith("/agentmemory/livez")) { probeCount++; return serverUp ? new Response("ok", { status: 200 }) : new Response("", { status: 500 }); } return new Response("boom", { status: 500, statusText: "Internal Server Error" }); }); const localKv = new InMemoryKV(undefined); await handleToolCall("memory_save", { content: "first fallback" }, localKv); expect(probeCount).toBe(1); serverUp = false; await handleToolCall("memory_save", { content: "second fallback" }, localKv); expect(probeCount).toBe(2); }); it("forwards non-essential tools to /agentmemory/mcp/call (#234)", async () => { const calls: Array<{ url: string; body?: unknown }> = []; installFetch((url, init) => { if (url.endsWith("/agentmemory/livez")) { return new Response("ok", { status: 200 }); } if (url.endsWith("/agentmemory/mcp/call")) { const body = init?.body ? JSON.parse(init.body as string) : null; calls.push({ url, body }); return new Response( JSON.stringify({ content: [ { type: "text", text: JSON.stringify({ saved: "lesson_xyz" }), }, ], }), { status: 200, headers: { "content-type": "application/json" } }, ); } return new Response("not found", { status: 404 }); }); const res = await handleToolCall("memory_lesson_save", { title: "Always pin lockfiles", content: "...", }); const body = JSON.parse(res.content[0].text); expect(body.saved).toBe("lesson_xyz"); expect(calls).toHaveLength(1); expect(calls[0].body).toEqual({ name: "memory_lesson_save", arguments: { title: "Always pin lockfiles", content: "..." }, }); }); it("rejects non-essential tools when no server is reachable (#234)", async () => { installFetch(() => { throw new Error("ECONNREFUSED"); }); const localKv = new InMemoryKV(undefined); await expect( handleToolCall("memory_lesson_save", { title: "x" }, localKv), ).rejects.toThrow(/Unknown tool: memory_lesson_save/); }); it("does not retry local after a validation error", async () => { const fetchFn = installFetch((url) => { if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); return new Response("{}", { status: 200 }); }); const localKv = new InMemoryKV(undefined); await expect( handleToolCall("memory_save", { content: "" }, localKv), ).rejects.toThrow("content is required"); const remembersCalled = fetchFn.mock.calls.some(([url]) => String(url).endsWith("/agentmemory/remember"), ); expect(remembersCalled).toBe(false); }); it("AGENTMEMORY_FORCE_PROXY=1 skips livez probe and trusts the server", async () => { process.env["AGENTMEMORY_FORCE_PROXY"] = "1"; const calls: string[] = []; installFetch((url, init) => { calls.push(url); if (url.endsWith("/agentmemory/livez")) { throw new Error("probe should be skipped"); } if (url.endsWith("/agentmemory/remember")) { return new Response(JSON.stringify({ id: "m-1", action: "created" }), { status: 200, headers: { "content-type": "application/json" }, }); } return new Response("not found", { status: 404 }); }); try { await handleToolCall("memory_save", { content: "force-proxy" }); expect(calls.some((u) => u.endsWith("/agentmemory/livez"))).toBe(false); expect(calls.some((u) => u.endsWith("/agentmemory/remember"))).toBe(true); } finally { delete process.env["AGENTMEMORY_FORCE_PROXY"]; } }); it("logs probe failure to stderr so sandboxed clients can diagnose silently dropped tools", async () => { installFetch((url) => { if (url.endsWith("/agentmemory/livez")) { throw new Error("ECONNREFUSED 127.0.0.1:3111"); } return new Response("not found", { status: 404 }); }); const writes: string[] = []; const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array) => { writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); return true; }) as typeof process.stderr.write; try { const localKv = new InMemoryKV(undefined); await handleToolCall("memory_save", { content: "diag" }, localKv); } finally { process.stderr.write = origWrite; } const joined = writes.join(""); expect(joined).toMatch(/livez probe .* failed/); expect(joined).toMatch(/AGENTMEMORY_FORCE_PROXY/); }); it("local fallback tools/list returns all 7 IMPLEMENTED_TOOLS regardless of AGENTMEMORY_TOOLS env (#234)", async () => { const { handleToolsList } = await import("../src/mcp/standalone.js"); installFetch(() => { throw new Error("ECONNREFUSED"); }); delete process.env["AGENTMEMORY_TOOLS"]; const before = await handleToolsList(); const beforeTools = before.tools as Array<{ name: string }>; expect(beforeTools.map((t) => t.name).sort()).toEqual([ "memory_audit", "memory_export", "memory_governance_delete", "memory_recall", "memory_save", "memory_sessions", "memory_smart_search", ]); expect(beforeTools).toHaveLength(7); resetHandleForTests(); process.env["AGENTMEMORY_TOOLS"] = "core"; const core = await handleToolsList(); expect((core.tools as unknown[]).length).toBe(7); delete process.env["AGENTMEMORY_TOOLS"]; }); it("AGENTMEMORY_PROBE_TIMEOUT_MS overrides the default probe timeout", async () => { process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"] = "50"; let probeStarted = 0; installFetch((url) => { if (url.endsWith("/agentmemory/livez")) { probeStarted++; return new Response("ok", { status: 200 }); } return new Response("not found", { status: 404 }); }); try { const localKv = new InMemoryKV(undefined); await handleToolCall("memory_save", { content: "timeout-knob" }, localKv); expect(probeStarted).toBe(1); } finally { delete process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"]; } }); });