From 89e9672da19872a53f50ba0f8ba5aaec28e67fb5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 11 May 2026 09:17:54 +0100 Subject: [PATCH 1/2] fix(mcp): probe diagnostics + sandbox escape hatch (#234, #278) Three small follow-ups to v0.9.6 reported live on #234 and #278: 1. @agentmemory/mcp shim silently degraded to 7-tool local fallback for sandboxed MCP clients (Flatpak VS Codeium / Roo Code) because the 500ms livez probe failed inside the sandbox network namespace and the catch swallowed the error. Probe now logs the URL, status, and reason to stderr; default timeout raised to 2000ms; AGENTMEMORY_PROBE_TIMEOUT_MS overrides the timeout; AGENTMEMORY_FORCE_PROXY=1 skips the probe and trusts AGENTMEMORY_URL outright for sandboxed clients that can reach the server through a known route but not the host loopback. Closes the #234 follow-up reported by @jcalfee. 2. Docker compose stack persisted state to an ephemeral container path. iii-config.docker.yaml used file_path: ./data/state_store.db, which the engine resolved against its container WORKDIR=/home/nonroot -- not the /data mount where iii-data is bound. State and stream stores were silently written to the container layer and lost on every docker compose down. Paths are now absolute (/data/state_store.db and /data/stream_store). Existing users need a one-time docker compose down -v before upgrade. 3. CLI banner leaked "which: no iii in ..." when iii wasn't on PATH. execFileSync default stdio inherits stderr; GNU which writes the miss line to stderr (exit 1). Switched to explicit stdio: ["ignore", "pipe", "pipe"] in whichBinary(). 4. docker-compose.yml now caps engine container log size at 30MB total (json-file driver, max-size=10m, max-file=3) so the iiidev/iii crash/restart spam reported by @satabd in #278 can no longer fill the host disk. The upstream engine spam itself needs filing against iii-hq/iii -- this is the compose-side guardrail. Version 0.9.6 -> 0.9.7 across package.json, packages/mcp/package.json, plugin/.claude-plugin/plugin.json, src/version.ts, src/types.ts ExportData literal, src/functions/export-import.ts supportedVersions, and the export round-trip test expectation. --- CHANGELOG.md | 18 ++++++++ docker-compose.yml | 5 +++ iii-config.docker.yaml | 4 +- package.json | 2 +- packages/mcp/package.json | 2 +- plugin/.claude-plugin/plugin.json | 2 +- src/cli.ts | 5 ++- src/functions/export-import.ts | 2 +- src/mcp/rest-proxy.ts | 35 ++++++++++++++-- src/types.ts | 2 +- src/version.ts | 2 +- test/export-import.test.ts | 2 +- test/mcp-standalone-proxy.test.ts | 68 +++++++++++++++++++++++++++++++ 13 files changed, 135 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb88f393..98994b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.9.7] — 2026-05-11 + +Three small follow-ups to v0.9.6 reported live by [@jcalfee](https://github.com/jcalfee) on [#234](https://github.com/rohitg00/agentmemory/issues/234): the `@agentmemory/mcp` shim silently degraded to a 7-tool local fallback for sandboxed MCP clients (Flatpak VS Codeium / Roo Code) because its 500 ms `livez` probe failed inside the sandbox network namespace and the catch swallowed the error; the Docker compose stack persisted state to an ephemeral container path instead of the named volume; and a `which iii` lookup leaked a "no iii in $PATH" line to the CLI banner. + +### Fixed + +- **`@agentmemory/mcp` standalone shim now surfaces probe failures and ships an escape hatch for sandboxed clients.** The `livez` probe in `src/mcp/rest-proxy.ts` used a 500 ms timeout and silently swallowed every failure; sandboxed clients (Flatpak VS Codeium spawning the shim from inside its bubblewrap network namespace, Snap-packaged editors, restrictive container runtimes) hit a connection failure on the probe, fell back to the 7-tool `IMPLEMENTED_TOOLS` set, and had no log line explaining why. The probe now writes the URL, HTTP status (or thrown reason), and the active timeout to `stderr` on every failure, the default timeout is raised to 2000 ms, `AGENTMEMORY_PROBE_TIMEOUT_MS` overrides it for slow loopbacks, and `AGENTMEMORY_FORCE_PROXY=1` skips the probe entirely and trusts `AGENTMEMORY_URL` — the right escape hatch when the shim is reachable to the server via a known route but can't see the host's `localhost`. (closes [#234](https://github.com/rohitg00/agentmemory/issues/234) follow-up, thanks [@jcalfee](https://github.com/jcalfee) for the host-vs-Flatpak repro) + +- **Docker compose stack no longer loses state on `docker compose down`.** `iii-config.docker.yaml` configured `iii-state` and `iii-stream` with relative `file_path: ./data/...`, which the engine resolves against its container `WORKDIR=/home/nonroot` — not the `/data` mount where the named `iii-data` volume lives. State and stream stores were written to the ephemeral container layer and discarded on every container restart, so memories, BM25 index, and stream backlog vanished. Both paths are now absolute (`/data/state_store.db` and `/data/stream_store`), routing writes through the named volume as the compose file always intended. Existing users need a one-time `docker compose down -v` to clear the old empty volume layout before the upgrade. + +- **CLI banner no longer leaks `which: no iii in $PATH` when iii isn't installed.** `whichBinary()` in `src/cli.ts` called `execFileSync("which", ["iii"])` with default stdio, which inherits the child's `stderr` to the parent process — and GNU `which` writes "no iii in (...)" to `stderr` (with exit 1) on miss. The catch swallowed the throw but the stderr line had already drained into the user's terminal between the `agentmemory` banner and the "iii-engine ready" line. `stdio: ["ignore", "pipe", "pipe"]` now captures both streams. Pure cosmetic, no behavior change. + +- **Docker compose stack now caps engine container log size at 30 MB total.** [@satabd](https://github.com/satabd) reported the `iiidev/iii:0.11.2` engine container filling a host disk with a 129 GB `-json.log` when the engine fell into a crash/restart spam loop ([#278](https://github.com/rohitg00/agentmemory/issues/278)). The compose service now sets `logging.driver: json-file` with `max-size: 10m` and `max-file: 3`, so unbounded engine stdout/stderr can no longer eat the host's disk. The upstream engine spam itself is filed against `iiidev/iii` — this is the compose-side guardrail. + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.6 → 0.9.7 to lockstep with the main package. + ## [0.9.6] — 2026-05-10 Three reliability fixes that close field-reported regressions in v0.9.5: search/recall returns saved memories again, the standalone MCP shim no longer caps non-Claude clients at a 7-tool subset, and the Claude Code session/subagent hooks no longer block agent startup for up to five seconds against a slow or unreachable REST server. diff --git a/docker-compose.yml b/docker-compose.yml index 2e379ce2..bed761ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,11 @@ services: - iii-data:/data - ./iii-config.docker.yaml:/app/config.yaml:ro restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: iii-data: diff --git a/iii-config.docker.yaml b/iii-config.docker.yaml index bc53c825..aa6c3b99 100644 --- a/iii-config.docker.yaml +++ b/iii-config.docker.yaml @@ -13,7 +13,7 @@ workers: name: kv config: store_method: file_based - file_path: ./data/state_store.db + file_path: /data/state_store.db - name: iii-queue config: adapter: @@ -34,7 +34,7 @@ workers: name: kv config: store_method: file_based - file_path: ./data/stream_store + file_path: /data/stream_store - name: iii-observability config: enabled: true diff --git a/package.json b/package.json index a462e8c7..c99db69c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentmemory/agentmemory", - "version": "0.9.6", + "version": "0.9.7", "description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives", "type": "module", "main": "dist/index.mjs", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 286b0c6d..f5b0d386 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@agentmemory/mcp", - "version": "0.9.6", + "version": "0.9.7", "description": "Standalone MCP server for agentmemory — thin shim that re-exposes @agentmemory/agentmemory's MCP entrypoint", "type": "module", "bin": { diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 2b897ba3..bd123a43 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentmemory", - "version": "0.9.6", + "version": "0.9.7", "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 51 MCP tools, 4 skills, real-time viewer.", "author": { "name": "Rohit Ghumare", diff --git a/src/cli.ts b/src/cli.ts index 73535e53..ced7a149 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -183,7 +183,10 @@ function findIiiConfig(): string { function whichBinary(name: string): string | null { const cmd = IS_WINDOWS ? "where" : "which"; try { - const out = execFileSync(cmd, [name], { encoding: "utf-8" }); + const out = execFileSync(cmd, [name], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); const first = out .split(/\r?\n/) .map((line) => line.trim()) diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index 0273534b..5e0fcf5f 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -176,7 +176,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { const strategy = data.strategy || "merge"; const importData = data.exportData; - const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6"]); + const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7"]); if (!supportedVersions.has(importData.version)) { return { success: false, diff --git a/src/mcp/rest-proxy.ts b/src/mcp/rest-proxy.ts index 8a188fbf..5c86bcd5 100644 --- a/src/mcp/rest-proxy.ts +++ b/src/mcp/rest-proxy.ts @@ -1,8 +1,20 @@ const DEFAULT_URL = "http://localhost:3111"; -const HEALTH_PROBE_TIMEOUT_MS = 500; +const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 2_000; const CALL_TIMEOUT_MS = 15_000; const LOCAL_MODE_TTL_MS = 30_000; +function probeTimeoutMs(): number { + const raw = process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"]; + if (!raw) return DEFAULT_HEALTH_PROBE_TIMEOUT_MS; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_HEALTH_PROBE_TIMEOUT_MS; +} + +function forceProxy(): boolean { + const raw = process.env["AGENTMEMORY_FORCE_PROXY"]; + return raw === "1" || raw === "true"; +} + export interface ProxyHandle { mode: "proxy"; baseUrl: string; @@ -29,14 +41,23 @@ function authHeader(): Record { } async function probe(url: string): Promise { + const timeout = probeTimeoutMs(); try { const res = await fetch(`${url}/agentmemory/livez`, { method: "GET", headers: authHeader(), - signal: AbortSignal.timeout(HEALTH_PROBE_TIMEOUT_MS), + signal: AbortSignal.timeout(timeout), }); + if (!res.ok) { + process.stderr.write( + `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez -> ${res.status} ${res.statusText}; falling back to local InMemoryKV (set AGENTMEMORY_FORCE_PROXY=1 to skip the probe)\n`, + ); + } return res.ok; - } catch { + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez failed in ${timeout}ms: ${err instanceof Error ? err.message : String(err)}; falling back to local InMemoryKV (set AGENTMEMORY_FORCE_PROXY=1 to skip the probe, or raise AGENTMEMORY_PROBE_TIMEOUT_MS)\n`, + ); return false; } } @@ -58,8 +79,14 @@ export async function resolveHandle(): Promise { } if (probeInFlight) return probeInFlight; const url = baseUrl(); + const skipProbe = forceProxy(); probeInFlight = (async () => { - const up = await probe(url); + const up = skipProbe ? true : await probe(url); + if (skipProbe) { + process.stderr.write( + `[@agentmemory/mcp] AGENTMEMORY_FORCE_PROXY set; skipping livez probe and trusting ${url}\n`, + ); + } if (up) { const handle: ProxyHandle = { mode: "proxy", diff --git a/src/types.ts b/src/types.ts index cc2d7bc2..6385058d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -279,7 +279,7 @@ export interface ExportPagination { } export interface ExportData { - version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6"; + version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7"; exportedAt: string; sessions: Session[]; observations: Record; diff --git a/src/version.ts b/src/version.ts index d894f013..b4f684d9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.9.6"; +export const VERSION = "0.9.7"; diff --git a/test/export-import.test.ts b/test/export-import.test.ts index 5b50f671..4e8de918 100644 --- a/test/export-import.test.ts +++ b/test/export-import.test.ts @@ -119,7 +119,7 @@ describe("Export/Import Functions", () => { it("export produces valid ExportData structure", async () => { const result = (await sdk.trigger("mem::export", {})) as ExportData; - expect(result.version).toBe("0.9.6"); + expect(result.version).toBe("0.9.7"); expect(result.exportedAt).toBeDefined(); expect(result.sessions.length).toBe(1); expect(result.sessions[0].id).toBe("ses_1"); diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts index ef093850..debc7eb9 100644 --- a/test/mcp-standalone-proxy.test.ts +++ b/test/mcp-standalone-proxy.test.ts @@ -197,4 +197,72 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { ); 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("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"]; + } + }); }); From b9c43360ea3ab11c6ecd6733901f09d98951168e Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 11 May 2026 13:45:55 +0100 Subject: [PATCH 2/2] fix(mcp): AGENTMEMORY_DEBUG flag + loud unexpected-shape warning @jcalfee on #234 confirmed the v0.9.6 fix (and PR #280's escape hatch) still surfaces 7 tools in Roo Code even though: - Flatpak share=network is on (probe succeeds via curl in-sandbox) - server's /agentmemory/mcp/tools returns 51 tools via curl - AGENTMEMORY_FORCE_PROXY=1 is set Roo Code shows 7 tools even when docker is down -- strong signal of client-side caching at the Roo Code layer, not a shim regression. But the shim has zero visibility into what it actually returns to the MCP client right now: success path is silent, only failure paths log. Add AGENTMEMORY_DEBUG=1 (or =true) so the shim writes to stderr: - which mode handle.mode resolved to (proxy vs local) and baseUrl - shape of the /agentmemory/mcp/tools response (keys + tools type) - count of tools returned to the MCP client - tool names in the local-fallback list if we hit it Also: previously, when the server returned a JSON shape that wasn't {tools: Array}, the shim silently fell back to the 7-tool local list with no log line. Now that path warns to stderr unconditionally, pointing at AGENTMEMORY_DEBUG=1 for inspection. This is diagnosis enablement -- not a fix for whatever is making Roo Code show 7 tools. Once @jcalfee can run the shim with AGENTMEMORY_DEBUG=1 and share the stderr output, we'll know whether the shim returns 51 (Roo Code bug) or 7 (deeper shim bug). --- src/mcp/standalone.ts | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index 2a1d232b..6026f715 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -370,20 +370,40 @@ const transport = createStdioTransport(async (method, params) => { return {}; case "tools/list": { - // When a server is reachable, expose every tool it advertises (51 - // when AGENTMEMORY_TOOLS=all on the server). Without this, the shim - // capped non-Claude clients at the local 7-tool set even with the - // server up (issue #234). + const debug = process.env["AGENTMEMORY_DEBUG"] === "1" || process.env["AGENTMEMORY_DEBUG"] === "true"; const handle = await resolveHandle(); announceMode(handle); + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: handle.mode=${handle.mode}${handle.mode === "proxy" ? ` baseUrl=${handle.baseUrl}` : ""}\n`, + ); + } if (handle.mode === "proxy") { try { const remote = (await handle.call("/agentmemory/mcp/tools", { method: "GET", })) as { tools?: unknown } | null; + if (debug) { + const shape = remote === null + ? "null" + : typeof remote !== "object" + ? typeof remote + : `keys=${Object.keys(remote as object).join(",")} toolsType=${Array.isArray((remote as { tools?: unknown }).tools) ? `array(len=${((remote as { tools: unknown[] }).tools).length})` : typeof (remote as { tools?: unknown }).tools}`; + process.stderr.write( + `[@agentmemory/mcp] tools/list: remote response shape: ${shape}\n`, + ); + } if (remote && Array.isArray(remote.tools)) { + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: returning ${remote.tools.length} tools from server\n`, + ); + } return { tools: remote.tools }; } + process.stderr.write( + `[@agentmemory/mcp] tools/list: server returned unexpected shape (no .tools array); falling back to local IMPLEMENTED_TOOLS list. Set AGENTMEMORY_DEBUG=1 to inspect response.\n`, + ); } catch (err) { process.stderr.write( `[@agentmemory/mcp] tools/list proxy failed: ${err instanceof Error ? err.message : String(err)}; falling back to local list\n`, @@ -391,9 +411,13 @@ const transport = createStdioTransport(async (method, params) => { invalidateHandle(); } } - return { - tools: getVisibleTools().filter((t) => IMPLEMENTED_TOOLS.has(t.name)), - }; + const fallback = getVisibleTools().filter((t) => IMPLEMENTED_TOOLS.has(t.name)); + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: returning ${fallback.length} local fallback tools (${fallback.map((t) => t.name).join(",")})\n`, + ); + } + return { tools: fallback }; } case "tools/call": {