diff --git a/.env.example b/.env.example index f3b97be6..a2a639ea 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,7 @@ # AGENTMEMORY_DEBUG=1 # Trace MCP shim probe + standalone fallback decisions to stderr # AGENTMEMORY_FORCE_PROXY=1 # Skip the MCP shim livez probe and trust AGENTMEMORY_URL (for sandboxed MCP clients that can't reach localhost) # AGENTMEMORY_REQUIRE_SERVER=1 # Make the MCP shim fail instead of using local fallback when AGENTMEMORY_URL is unreachable +# AGENTMEMORY_NO_FALLBACK=1 # Alias for AGENTMEMORY_REQUIRE_SERVER=1 # AGENTMEMORY_REMOTE_REQUIRED=1 # Alias for AGENTMEMORY_REQUIRE_SERVER=1 # AGENTMEMORY_LIVEZ_TIMEOUT_MS=2000 # MCP shim livez probe timeout; takes precedence over AGENTMEMORY_PROBE_TIMEOUT_MS # AGENTMEMORY_PROBE_TIMEOUT_MS=2000 # Older MCP shim livez probe timeout name diff --git a/README.md b/README.md index ad0e7713..3e0a16ea 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,7 @@ agentmemory connect claude-code --with-hooks This merges the same hook commands into `~/.claude/settings.json` with absolute paths resolved to the bundled `plugin/` directory of the currently installed `@agentmemory/agentmemory` package. Re-run the command after upgrading agentmemory to refresh the paths. User entries in the same file are preserved; only previous agentmemory entries are replaced. Using the `/plugin install` path remains the recommended approach. For remote or protected deployments, launch Claude Code with `AGENTMEMORY_URL` and `AGENTMEMORY_SECRET` set. The plugin passes both values through to its bundled MCP server; when `AGENTMEMORY_URL` is empty, the MCP shim uses `http://localhost:3111`. -If agentmemory is your central cross-agent memory instance, add `AGENTMEMORY_REQUIRE_SERVER=1` to the MCP server env block. This makes the shim fail hard when `/agentmemory/livez` or a proxied tool call fails instead of silently switching to the local `~/.agentmemory/standalone.json` fallback. The error tells the host to start `npx @agentmemory/agentmemory`. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1` and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` overrides the livez probe timeout and takes precedence over the older `AGENTMEMORY_PROBE_TIMEOUT_MS`. +If agentmemory is your central cross-agent memory instance, add `AGENTMEMORY_REQUIRE_SERVER=1` to the MCP server env block. This makes the shim fail hard when `/agentmemory/livez` or a proxied tool call fails instead of silently switching to the local `~/.agentmemory/standalone.json` fallback. The error tells the host to start `npx @agentmemory/agentmemory`. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1`, `AGENTMEMORY_NO_FALLBACK=1`, and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. When fallback-capable MCP tool calls succeed, their JSON text payload includes `storage_mode: "local"` or `storage_mode: "remote"` so hosts can tell which store answered. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` overrides the livez probe timeout and takes precedence over the older `AGENTMEMORY_PROBE_TIMEOUT_MS`. ### Codex CLI (Codex plugin platform) @@ -551,7 +551,7 @@ codex plugin add agentmemory@agentmemory The Codex plugin ships from the same `plugin/` directory as the Claude Code plugin. It registers: -- `@agentmemory/mcp` as an MCP server (proxies the server-advertised 8 core tools by default when `AGENTMEMORY_URL` points at a running agentmemory server; start the server with `--tools all` for all 61 tools; falls back to 7 tools locally when no server is reachable unless `AGENTMEMORY_REQUIRE_SERVER=1` or `AGENTMEMORY_REMOTE_REQUIRED=1` is set) +- `@agentmemory/mcp` as an MCP server (proxies the server-advertised 8 core tools by default when `AGENTMEMORY_URL` points at a running agentmemory server; start the server with `--tools all` for all 61 tools; falls back to 7 tools locally when no server is reachable unless `AGENTMEMORY_REQUIRE_SERVER=1`, `AGENTMEMORY_NO_FALLBACK=1`, or `AGENTMEMORY_REMOTE_REQUIRED=1` is set) - 6 lifecycle hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` - 8 invocable skills: `/recall`, `/remember`, `/session-history`, `/forget`, `/recap`, `/handoff`, `/commit-context`, `/commit-history`, plus 7 reference skills the agent loads on demand (MCP tools, REST API, config, agents, hooks, architecture, and the skill-authoring guide) @@ -696,7 +696,7 @@ Kilo Code uses `mcp` instead of `mcpServers`: } ``` -For central memory deployments where an empty local fallback would be misleading, also pass `"AGENTMEMORY_REQUIRE_SERVER": "1"`. With this flag, livez failures and proxy-call failures return a clear MCP error instead of answering from `~/.agentmemory/standalone.json`. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1` and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. `AGENTMEMORY_FORCE_PROXY=1` only skips the livez probe; it does not by itself disable local fallback after a proxy call fails. Use `AGENTMEMORY_LIVEZ_TIMEOUT_MS` to tune the livez probe timeout; it takes precedence over `AGENTMEMORY_PROBE_TIMEOUT_MS`. +For central memory deployments where an empty local fallback would be misleading, also pass `"AGENTMEMORY_REQUIRE_SERVER": "1"`. With this flag, livez failures and proxy-call failures return a clear MCP error instead of answering from `~/.agentmemory/standalone.json`. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1`, `AGENTMEMORY_NO_FALLBACK=1`, and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. Successful fallback-capable tool responses include `storage_mode: "local"` or `storage_mode: "remote"` in their JSON text payload. `AGENTMEMORY_FORCE_PROXY=1` only skips the livez probe; it does not by itself disable local fallback after a proxy call fails. Use `AGENTMEMORY_LIVEZ_TIMEOUT_MS` to tune the livez probe timeout; it takes precedence over `AGENTMEMORY_PROBE_TIMEOUT_MS`. | Agent | Config file | Notes | |---|---|---| @@ -748,9 +748,9 @@ GitHub Copilot in VS Code uses `servers` instead of `mcpServers`. Put this in `. } ``` -Start `npx @agentmemory/agentmemory` in another terminal first when you want the persistent memory server. By default the server advertises the 8 core MCP tools; start it with `npx @agentmemory/agentmemory --tools all` or `AGENTMEMORY_TOOLS=all npx @agentmemory/agentmemory` when you want the full 61-tool surface. If the shim cannot reach that server, it falls back to the local 7-tool standalone surface unless you also set `"AGENTMEMORY_REQUIRE_SERVER": "1"` or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` in `env`. +Start `npx @agentmemory/agentmemory` in another terminal first when you want the persistent memory server. By default the server advertises the 8 core MCP tools; start it with `npx @agentmemory/agentmemory --tools all` or `AGENTMEMORY_TOOLS=all npx @agentmemory/agentmemory` when you want the full 61-tool surface. If the shim cannot reach that server, it falls back to the local 7-tool standalone surface unless you also set `"AGENTMEMORY_REQUIRE_SERVER": "1"`, `"AGENTMEMORY_NO_FALLBACK": "1"`, or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` in `env`. -**Sandboxed MCP clients** (Flatpak / Snap / restrictive containers) that can't reach the host's `localhost`: also set `"AGENTMEMORY_FORCE_PROXY": "1"` in the `env` block, and point `AGENTMEMORY_URL` at a route the sandbox can actually reach. Add `"AGENTMEMORY_REQUIRE_SERVER": "1"` or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` when the client should fail rather than use the local fallback if that route is still broken. If `AGENTMEMORY_SECRET` is set, that route must be HTTPS or a loopback tunnel; the MCP shim refuses to send bearer auth over plaintext HTTP to non-loopback hosts. See [#234](https://github.com/rohitg00/agentmemory/issues/234) for the diagnostic walkthrough. +**Sandboxed MCP clients** (Flatpak / Snap / restrictive containers) that can't reach the host's `localhost`: also set `"AGENTMEMORY_FORCE_PROXY": "1"` in the `env` block, and point `AGENTMEMORY_URL` at a route the sandbox can actually reach. Add `"AGENTMEMORY_REQUIRE_SERVER": "1"`, `"AGENTMEMORY_NO_FALLBACK": "1"`, or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` when the client should fail rather than use the local fallback if that route is still broken. If `AGENTMEMORY_SECRET` is set, that route must be HTTPS or a loopback tunnel; the MCP shim refuses to send bearer auth over plaintext HTTP to non-loopback hosts. See [#234](https://github.com/rohitg00/agentmemory/issues/234) for the diagnostic walkthrough. ### Programmatic access (Python / Rust / Node) @@ -1053,7 +1053,7 @@ For native Ollama embeddings, run Ollama locally and set `EMBEDDING_PROVIDER=oll 61 tools, 6 resources, 3 prompts, and 15 skills, the most comprehensive MCP memory toolkit for any agent. -> **MCP shim vs full server:** the published `@agentmemory/mcp` package is a thin shim. It exposes the server's MCP surface **only when it can reach a running agentmemory server** via `AGENTMEMORY_URL` (proxy mode). The server advertises the 8 core tools by default; set `AGENTMEMORY_TOOLS=all` on the server to expose the full 61-tool surface. With no server reachable, the shim falls back to a 7-tool local set (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). Lesson lifecycle tools such as `memory_lesson_save` require the full server/proxy surface. Set `AGENTMEMORY_REQUIRE_SERVER=1` or `AGENTMEMORY_REMOTE_REQUIRED=1` in the shim's env when that fallback would hide an outage; then livez failures and proxy-call failures return an MCP error instead. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` tunes the livez probe timeout and wins over `AGENTMEMORY_PROBE_TIMEOUT_MS`. The `AGENTMEMORY_TOOLS=core|all` env var is a *server-side* flag — setting it in the shim's `env` block has no effect. If you see only 7 tools in Cursor / OpenCode / Gemini CLI, start `npx @agentmemory/agentmemory` (or the Docker stack) and set `AGENTMEMORY_URL=http://localhost:3111`. +> **MCP shim vs full server:** the published `@agentmemory/mcp` package is a thin shim. It exposes the server's MCP surface **only when it can reach a running agentmemory server** via `AGENTMEMORY_URL` (proxy mode). The server advertises the 8 core tools by default; set `AGENTMEMORY_TOOLS=all` on the server to expose the full 61-tool surface. With no server reachable, the shim falls back to a 7-tool local set (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). Successful calls to those fallback-capable tools include `storage_mode: "local"` or `storage_mode: "remote"` in the JSON text payload. Lesson lifecycle tools such as `memory_lesson_save` require the full server/proxy surface. Set `AGENTMEMORY_REQUIRE_SERVER=1`, `AGENTMEMORY_NO_FALLBACK=1`, or `AGENTMEMORY_REMOTE_REQUIRED=1` in the shim's env when that fallback would hide an outage; then livez failures and proxy-call failures return an MCP error instead. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` tunes the livez probe timeout and wins over `AGENTMEMORY_PROBE_TIMEOUT_MS`. The `AGENTMEMORY_TOOLS=core|all` env var is a *server-side* flag — setting it in the shim's `env` block has no effect. If you see only 7 tools in Cursor / OpenCode / Gemini CLI, start `npx @agentmemory/agentmemory` (or the Docker stack) and set `AGENTMEMORY_URL=http://localhost:3111`. ### 61 Tools @@ -1207,7 +1207,7 @@ Most agents (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): } ``` -Merge the `agentmemory` entry into your host's existing `mcpServers` object rather than replacing the file. For sandboxed clients that can't reach the host's `localhost`, add `"AGENTMEMORY_FORCE_PROXY": "1"` to the env block and set `AGENTMEMORY_URL` to a route the sandbox can reach. For central cross-agent memory, add `"AGENTMEMORY_REQUIRE_SERVER": "1"` or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` so the shim errors instead of falling back to its local standalone store when the server route breaks. If `AGENTMEMORY_SECRET` is set, use HTTPS or a loopback tunnel; plaintext HTTP to non-loopback hosts is refused before bearer auth is sent. +Merge the `agentmemory` entry into your host's existing `mcpServers` object rather than replacing the file. For sandboxed clients that can't reach the host's `localhost`, add `"AGENTMEMORY_FORCE_PROXY": "1"` to the env block and set `AGENTMEMORY_URL` to a route the sandbox can reach. For central cross-agent memory, add `"AGENTMEMORY_REQUIRE_SERVER": "1"`, `"AGENTMEMORY_NO_FALLBACK": "1"`, or `"AGENTMEMORY_REMOTE_REQUIRED": "1"` so the shim errors instead of falling back to its local standalone store when the server route breaks. Successful fallback-capable tool responses include `storage_mode: "local"` or `storage_mode: "remote"` in their JSON text payload. If `AGENTMEMORY_SECRET` is set, use HTTPS or a loopback tunnel; plaintext HTTP to non-loopback hosts is refused before bearer auth is sent. MCP clients that support Streamable HTTP can connect directly to the running server without the stdio shim: diff --git a/docs/todos/2026-06-19-issue-273-storage-mode/plan.md b/docs/todos/2026-06-19-issue-273-storage-mode/plan.md new file mode 100644 index 00000000..b698fafb --- /dev/null +++ b/docs/todos/2026-06-19-issue-273-storage-mode/plan.md @@ -0,0 +1,86 @@ +# Plan: Issue #273 Transparent Fallback Mode + +## TDD Steps + +1. Add RED tests in `test/mcp-standalone.test.ts`. + - Assert local `memory_save` includes `storage_mode: "local"` and still includes `saved`. + - Assert the stored memory record does not include `storage_mode`. + - Assert local `memory_recall` includes `storage_mode: "local"` for existing formats. + - Assert local `memory_smart_search` includes `storage_mode: "local"` while preserving `mode`. + - Assert local `memory_sessions`, `memory_export`, `memory_audit`, and `memory_governance_delete` include `storage_mode: "local"`. + +2. Add RED tests in `test/mcp-standalone-proxy.test.ts`. + - Assert successful dedicated proxy responses for the seven fallback-capable tools include `storage_mode: "remote"`. + - Assert proxy-call fallback and `AGENTMEMORY_FORCE_PROXY=1` failure fallback report `storage_mode: "local"`. + - Assert generic proxy-only tools remain unannotated. + - Add `AGENTMEMORY_NO_FALLBACK` cleanup in `afterEach`. + - Assert `AGENTMEMORY_NO_FALLBACK=1` rejects livez fallback, proxy-call fallback, and tools/list fallback. + +3. Run targeted tests and confirm they fail for the intended missing assertions. + +```sh +corepack pnpm exec vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts --exclude test/integration.test.ts --reporter verbose +``` + +## Implementation Steps + +1. Update `src/mcp/standalone.ts`. + - Add a small response helper that shallow-copies object payloads and adds `storage_mode`. + - Use `"remote"` in `handleProxy` for the seven fallback-capable tools. + - Use `"local"` in `handleLocal` for those same tools. + - Leave `handleProxyGeneric`, `tools/list`, and error responses unchanged. + +2. Update `src/mcp/rest-proxy.ts`. + - Add `envFlag("AGENTMEMORY_NO_FALLBACK")` to `requireServerMode()`. + - Add the alias to `requireServerModeFlag()` while preserving existing flag priority where possible. + - Update fallback stderr hints to mention the new alias. + +3. Update docs/config references. + - `.env.example` + - `README.md` + - `packages/mcp/README.md` + - `plugin/skills/agentmemory-config/SKILL.md` + - Regenerate `plugin/skills/agentmemory-config/REFERENCE.md` with `corepack pnpm run skills:gen`. + +## Verification Plan + +1. Targeted MCP tests: + +```sh +corepack pnpm exec vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts --exclude test/integration.test.ts --reporter verbose +``` + +2. Generated skill reference: + +```sh +corepack pnpm run skills:gen +corepack pnpm run skills:check +git diff -- plugin/skills/agentmemory-config/REFERENCE.md +``` + +3. Repo checks: + +```sh +corepack pnpm run lint +corepack pnpm run build +corepack pnpm test +git diff --check +semgrep scan --config p/default --error --metrics=off . +``` + +4. Before commit: + +```sh +git add ... +gitleaks protect --staged --redact +git status -sb --untracked-files=all +``` + +OSV is not required unless dependency, lockfile, container, vendored, or package-manager files change. + +## Review Focus + +- Verify the response helper does not mutate remote payload objects in-place. +- Verify `memory_export` adds only a top-level response field and does not inject `storage_mode` into exported records. +- Verify strict mode does not change default fallback behavior. +- Verify docs do not imply sync-on-reconnect was implemented. diff --git a/docs/todos/2026-06-19-issue-273-storage-mode/todo.md b/docs/todos/2026-06-19-issue-273-storage-mode/todo.md new file mode 100644 index 00000000..920c8b0d --- /dev/null +++ b/docs/todos/2026-06-19-issue-273-storage-mode/todo.md @@ -0,0 +1,179 @@ +# Issue #273: transparent fallback mode + +## Scope + +Repository: `/Users/A1538552/.codex/worktrees/acfd/agentmemory` +Branch: `issue/273-transparent-fallback-mode` +Remote target: `origin` (`https://github.com/wbugitlab1/agentmemory.git`) + +Issue: GitHub #273, "Feature request: Transparent fallback mode - indicate local storage in tool response & add strict/no-fallback option" + +## Sprint Contract + +Goal: make the standalone MCP shim visibly report whether a successful fallback-capable tool result came from the local fallback store or the remote agentmemory server, and add the requested `AGENTMEMORY_NO_FALLBACK=1` strict-mode alias. + +Scope: + +- Add response-level `storage_mode: "local" | "remote"` to successful JSON text payloads for the seven fallback-capable standalone MCP tools. +- Add `AGENTMEMORY_NO_FALLBACK=1` as an alias for the existing strict/no-local-fallback mode. +- Update focused tests and user-facing config docs. + +Non-goals: + +- No sync-on-reconnect, dirty queue, remote import, persistence reconciliation, or schema migration. +- No REST endpoint contract changes. +- No full server MCP handler response changes. +- No MCP tool count, registry, endpoint count, version, dependency, or package-manager changes. +- No generic proxy-only tool response rewriting. + +Acceptance criteria: + +- Local fallback responses for `memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, and `memory_governance_delete` include `storage_mode: "local"` in the existing `content[0].text` JSON object. +- Dedicated proxy responses for the same seven tools include `storage_mode: "remote"` in the existing `content[0].text` JSON object. +- Existing result fields remain top-level and unchanged; `memory_smart_search.mode` remains separate from `storage_mode`. +- `storage_mode` is response metadata only and is not persisted inside local memory records. +- `AGENTMEMORY_NO_FALLBACK=1` blocks local fallback on livez failure, proxy-call failure, and tools/list proxy failure. +- Existing strict aliases continue to behave as before. +- Generic proxy-only tools keep the forwarded server response shape. + +Intended verification: + +- RED targeted vitest run after tests are added. +- GREEN targeted vitest run after implementation. +- `corepack pnpm run skills:gen` for generated skill reference updates. +- `corepack pnpm run skills:check`. +- `corepack pnpm run lint`. +- `corepack pnpm run build`. +- `corepack pnpm test`. +- `git diff --check`. +- `semgrep scan --config p/default --error --metrics=off .`. +- Before commit: `gitleaks protect --staged --redact`. + +Stop conditions: + +- Any need to change persistence schema, REST/core API contract, auth/security boundaries, dependencies, or sync behavior. +- Divergent arena validity conclusions. +- Failing, skipped, flaky, or incomplete verification that would need acceptance. +- Any remote action against `upstream` or another non-origin target. + +## Issue Legitimacy Evidence + +- GitHub issue #273 is open and requests transparent fallback indication plus a strict/no-fallback option. +- Public issue search in the target fork found no duplicate for the exact fallback/storage-mode request. +- Prior local work for issue #768 already implemented strict/fail-loud behavior under `AGENTMEMORY_REQUIRE_SERVER`, `AGENTMEMORY_DISABLE_LOCAL_FALLBACK`, and `AGENTMEMORY_REMOTE_REQUIRED`. +- Current local code does not implement `AGENTMEMORY_NO_FALLBACK`. +- Current local fallback and proxy responses do not include `storage_mode`. + +Decision: valid with partial staleness. Implement response transparency and the requested strict alias; do not close as already fixed. + +## Arena Decision + +Arena synthesis: `/tmp/arena-273-solution-contract/synthesis.md` + +Selected solution: + +- Use top-level `storage_mode: "local" | "remote"` in the existing JSON text payload. +- Annotate only the seven fallback-capable shim tools. +- Add `AGENTMEMORY_NO_FALLBACK=1`. +- Keep sync-on-reconnect out of scope. + +Rejected alternatives: + +- Nested `_agentmemory` metadata envelope. +- MCP-envelope-only metadata. +- Additional text content item. +- Generic proxy response rewriting. +- REST/full-server response changes. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Local fallback responses include `storage_mode: "local"` | Targeted standalone MCP tests | Passed | RED failed as expected on missing field; GREEN passed 87/87 targeted tests | +| Proxy responses include `storage_mode: "remote"` | Targeted standalone proxy tests | Passed | RED failed as expected on missing field; GREEN passed 87/87 targeted tests | +| `storage_mode` is not persisted in memory records | Standalone persistence assertion | Passed | Targeted test asserts stored records and export rows lack `storage_mode` | +| `AGENTMEMORY_NO_FALLBACK=1` disables fallback | Proxy strict-mode tests | Passed | Targeted tests cover livez failure, proxy-call failure, tools/list failure, and diagnostic flag text | +| Docs mention marker and alias | Diff review plus skill generation | Passed | README, package README, `.env.example`, config skill, and generated reference updated | +| Full repo remains healthy | Skills check, lint, build, full tests, diff check, Semgrep, Gitleaks | Passed | Post-merge `skills:check`, lint, build, full tests, diff check, Semgrep, and PR-range Gitleaks passed | + +## Subagent Ledger + +| Workstream | Scope | Edits allowed | Result | Residual risk | +| --- | --- | --- | --- | --- | +| Validity arena candidates | Read-only issue/code evidence | No | Three candidates agreed the issue is partially valid; response transparency remains missing | None material | +| Validity arena judge | Read-only synthesis | No | Recommended implementation rather than closure | None material | +| Solution arena candidates | Read-only solution design | No | Candidates compared payload shapes and scope boundaries | Additive field compatibility risk remains | +| Solution arena judge | Read-only decision | No | Candidate 3 selected as base, with diagnostic and compatibility grafts | Exact-equality consumers may notice additive field | +| Plan review | Read-only plan/diff review | No | Confirmed scope and requested tighter helper, `build`, `skills:check`, and diagnostic assertion | Additive `memory_export` field remains a documented compatibility risk | +| Final requirements/test review | Read-only working-tree diff review | No | ACCEPT; no critical or important actionable issue | Reviewer relied on main-thread verification rather than rerunning checks | +| Final adversarial review | Read-only working-tree diff review | No | NO FINDINGS across MCP/REST boundaries, auth fallback behavior, persistence contamination, and compatibility | Reviewer relied on main-thread verification rather than rerunning checks | + +## Progress + +- Inspected `AGENTS.md`, repo status, branch, remotes, issue evidence, existing strict-mode code, and fallback/proxy tests. +- Created branch `issue/273-transparent-fallback-mode` from current worktree HEAD. +- Ran validity arena and recorded synthesis at `/tmp/arena-273-transparent-fallback/synthesis.md`. +- Ran solution-contract arena and recorded synthesis at `/tmp/arena-273-solution-contract/synthesis.md`. +- User asked for the recommendation; the recommended narrow solution was presented in German. +- User then requested Arena discussion followed by the GitHub feature loop; this is treated as approval for the scoped MCP response/config change while preserving the non-goals above. +- Added RED tests for local/remote `storage_mode`, no-persist metadata, generic proxy non-annotation, and `AGENTMEMORY_NO_FALLBACK`. +- Focused RED run failed on 21 expected missing-field/alias assertions. +- Implemented response metadata in `src/mcp/standalone.ts` and the strict alias in `src/mcp/rest-proxy.ts`. +- Focused GREEN run passed: `corepack pnpm exec vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts --exclude test/integration.test.ts --reporter verbose` reported 87/87 tests passing. +- Plan reviewer confirmed no scope blocker and requested a tighter helper; `storageModeTextResponse()` now annotates only object payloads and leaves unexpected non-object payloads unwrapped. +- Re-ran focused tests after the helper change; 87/87 tests passed. +- Updated README, package README, `.env.example`, and the config skill; ran `corepack pnpm run skills:gen`, which regenerated `plugin/skills/agentmemory-config/REFERENCE.md` with 73 recognized variables. +- Ran `corepack pnpm run skills:check`: passed, 15 skills checked. +- Ran `corepack pnpm run lint`: passed. +- Ran `corepack pnpm run build`: passed; emitted existing tsdown plugin-timing and dynamic-import warnings. +- Ran `corepack pnpm test`: passed, 202 files and 2808 tests. +- Ran `git diff --check`: passed. +- Ran `semgrep scan --config p/default --error --metrics=off .`: passed, 0 findings. +- Staged task-owned files and ran `gitleaks protect --staged --redact`: passed, no leaks found. +- Ran Codex Security diff scan on source-like diff files `src/mcp/rest-proxy.ts` and `src/mcp/standalone.ts`; 2/2 worklist rows completed, no candidates. Reports: + - `/tmp/codex-security-scans/agentmemory/72572b249168_20260619T144731Z/report.md` + - `/tmp/codex-security-scans/agentmemory/72572b249168_20260619T144731Z/report.html` +- Security scan goal completed with 220998 tokens used and about 2 minutes elapsed. +- Ran final read-only implementation reviewers; both returned no findings. +- Committed the implementation as `1ada97d8` (`fix: report mcp fallback storage mode`). +- Merged `origin/main` at `499b53fc` into the issue branch for the first post-implementation base update. +- After that merge, ran `corepack pnpm install --frozen-lockfile --ignore-scripts`, `skills:check`, lint, build, full tests, diff check, Semgrep, and Gitleaks PR-range verification. The first full test attempt was run concurrently with Semgrep and a full-history Gitleaks scan and produced timeout failures in unrelated tests; the same failed files passed 67/67 when rerun alone, and the full suite then passed 207 files / 2830 tests when rerun alone. +- A full-history `gitleaks detect --source . --redact` found 14 historical baseline leaks outside the PR commit range; the PR-range scan `gitleaks detect --source . --redact --log-opts origin/main..HEAD` passed with no leaks. +- `origin/main` then advanced to `ee6bb114` (merge of issue #282). Merged that current base into the issue branch without code conflicts; the merge touched shared config docs and brought #282 source/test files from main. +- Re-ran `corepack pnpm run skills:gen` after the latest merge; generated config reference count updated from 73 to 74 recognized variables because the new main base added another variable. +- Final post-latest-merge verification passed: + - `corepack pnpm run skills:check`: passed, 15 skills checked. + - `corepack pnpm run lint`: passed. + - `corepack pnpm run build`: passed; emitted existing tsdown plugin timing and ineffective dynamic import warnings. + - `corepack pnpm test`: passed, 207 files and 2833 tests. + - `git diff --check`: passed. + - `semgrep scan --config p/default --error --metrics=off .`: passed, 0 findings across 928 tracked files. + - `gitleaks detect --source . --redact --log-opts origin/main..HEAD`: passed, no leaks in the PR commit range. + +## Commands Run + +- `git status -sb --untracked-files=all` +- `git switch -c issue/273-transparent-fallback-mode` +- `git remote -v` +- `git worktree list --porcelain` +- `curl` public GitHub issue/search reads +- `rg` local code/doc/test searches +- `sed`/`jq` local code and script inspection +- Arena subagent fan-out and cross-judge +- `corepack pnpm exec vitest run test/mcp-standalone.test.ts test/mcp-standalone-proxy.test.ts --exclude test/integration.test.ts --reporter verbose` +- `corepack pnpm run skills:gen` +- `corepack pnpm run skills:check` +- `corepack pnpm run lint` +- `corepack pnpm run build` +- `corepack pnpm test` +- `git diff --check` +- `semgrep scan --config p/default --error --metrics=off .` +- `gitleaks protect --staged --redact` +- `gitleaks detect --source . --redact --log-opts origin/main..HEAD` + +## Final Notes + +- The implemented scope satisfies the accepted subset of issue #273: response transparency and a strict/no-fallback alias. +- Sync-on-reconnect remains out of scope and unimplemented. +- The only known compatibility caveat is the intentional additive `storage_mode` field in successful JSON object responses for the seven fallback-capable MCP shim tools. +- Current branch base includes latest observed `origin/main` at `ee6bb114`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index f3c156e1..cdabc058 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -41,8 +41,10 @@ back to a small local `~/.agentmemory/standalone.json` store when no server is reachable. For central cross-agent memory, set `AGENTMEMORY_REQUIRE_SERVER=1` in the MCP server environment so `/agentmemory/livez` failures and proxied tool failures return a clear error instead of looking like an empty local memory -store. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1` and -`AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. +store. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1`, +`AGENTMEMORY_NO_FALLBACK=1`, and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as +aliases. Successful fallback-capable tool responses include +`storage_mode: "local"` or `storage_mode: "remote"` in their JSON text payload. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` overrides the livez probe timeout and takes precedence over `AGENTMEMORY_PROBE_TIMEOUT_MS`. diff --git a/plugin/skills/agentmemory-config/REFERENCE.md b/plugin/skills/agentmemory-config/REFERENCE.md index 04a5186d..7cf493d2 100644 --- a/plugin/skills/agentmemory-config/REFERENCE.md +++ b/plugin/skills/agentmemory-config/REFERENCE.md @@ -3,7 +3,7 @@ Generated by scanning `src/` for `AGENTMEMORY_*` usage. Do not edit the block below by hand; run `corepack pnpm run skills:gen` after adding or removing a variable. Internal markers ending in two underscores are excluded. -Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 73 recognized variables: +Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 74 recognized variables: - `AGENTMEMORY_AGENT_ID` - `AGENTMEMORY_AGENT_SCOPE` @@ -53,6 +53,7 @@ Configuration is read from the environment and from `~/.agentmemory/.env` (no `e - `AGENTMEMORY_LLM_TIMEOUT_MS` - `AGENTMEMORY_LOCAL_EMBEDDING_MODEL_DIR` - `AGENTMEMORY_MCP_BLOCK` +- `AGENTMEMORY_NO_FALLBACK` - `AGENTMEMORY_OUTPUT_LANG` - `AGENTMEMORY_PREFER_CODEX_SDK` - `AGENTMEMORY_PROBE_TIMEOUT_MS` diff --git a/plugin/skills/agentmemory-config/SKILL.md b/plugin/skills/agentmemory-config/SKILL.md index a8d9aa3f..69b3adb1 100644 --- a/plugin/skills/agentmemory-config/SKILL.md +++ b/plugin/skills/agentmemory-config/SKILL.md @@ -22,7 +22,7 @@ AGENTMEMORY_INJECT_CONTEXT=true - Token-spending features ship OFF on purpose: `AGENTMEMORY_AUTO_COMPRESS` (LLM summaries) and `AGENTMEMORY_INJECT_CONTEXT` (auto context injection) both cost tokens proportional to tool-use frequency. - Tool visibility: `AGENTMEMORY_TOOLS=core` (default) or `all` for the full 61-tool set. - Auth: set `AGENTMEMORY_SECRET` to require `Authorization: Bearer` on the REST API. -- MCP shim fail-hard mode: set `AGENTMEMORY_REQUIRE_SERVER=1` in the MCP server env when agentmemory is a central cross-agent memory instance and local standalone fallback would hide an outage. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1` and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` overrides the livez probe timeout and takes precedence over `AGENTMEMORY_PROBE_TIMEOUT_MS`. +- MCP shim fail-hard mode: set `AGENTMEMORY_REQUIRE_SERVER=1` in the MCP server env when agentmemory is a central cross-agent memory instance and local standalone fallback would hide an outage. `AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1`, `AGENTMEMORY_NO_FALLBACK=1`, and `AGENTMEMORY_REMOTE_REQUIRED=1` are accepted as aliases. Successful fallback-capable tool responses include `storage_mode: "local"` or `storage_mode: "remote"` in their JSON text payload. `AGENTMEMORY_LIVEZ_TIMEOUT_MS` overrides the livez probe timeout and takes precedence over `AGENTMEMORY_PROBE_TIMEOUT_MS`. ## Ports diff --git a/src/mcp/rest-proxy.ts b/src/mcp/rest-proxy.ts index f5270d8b..8b44d671 100644 --- a/src/mcp/rest-proxy.ts +++ b/src/mcp/rest-proxy.ts @@ -40,7 +40,8 @@ export function requireServerMode(): boolean { return ( envFlag("AGENTMEMORY_REQUIRE_SERVER") || envFlag("AGENTMEMORY_DISABLE_LOCAL_FALLBACK") || - envFlag("AGENTMEMORY_REMOTE_REQUIRED") + envFlag("AGENTMEMORY_REMOTE_REQUIRED") || + envFlag("AGENTMEMORY_NO_FALLBACK") ); } @@ -48,6 +49,9 @@ export function requireServerModeFlag(): string { if (envFlag("AGENTMEMORY_DISABLE_LOCAL_FALLBACK")) { return "AGENTMEMORY_DISABLE_LOCAL_FALLBACK=1"; } + if (envFlag("AGENTMEMORY_NO_FALLBACK")) { + return "AGENTMEMORY_NO_FALLBACK=1"; + } if (envFlag("AGENTMEMORY_REMOTE_REQUIRED")) { return "AGENTMEMORY_REMOTE_REQUIRED=1"; } @@ -152,7 +156,7 @@ async function probe(url: string): Promise { process.stderr.write( strict ? `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez -> ${res.status ?? "?"} ${res.statusText ?? ""}; local fallback disabled by ${strictFlag}\n` - : `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez -> ${res.status ?? "?"} ${res.statusText ?? ""}; falling back to local InMemoryKV (set AGENTMEMORY_REMOTE_REQUIRED=1 to fail instead, or AGENTMEMORY_FORCE_PROXY=1 to skip the probe)\n`, + : `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez -> ${res.status ?? "?"} ${res.statusText ?? ""}; falling back to local InMemoryKV (set AGENTMEMORY_NO_FALLBACK=1 or AGENTMEMORY_REMOTE_REQUIRED=1 to fail instead, or AGENTMEMORY_FORCE_PROXY=1 to skip the probe)\n`, ); } return res.ok; @@ -160,7 +164,7 @@ async function probe(url: string): Promise { process.stderr.write( strict ? `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez failed in ${timeout}ms: ${err instanceof Error ? err.message : String(err)}; local fallback disabled by ${strictFlag}\n` - : `[@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_REMOTE_REQUIRED=1 to fail instead, AGENTMEMORY_FORCE_PROXY=1 to skip the probe, or raise AGENTMEMORY_LIVEZ_TIMEOUT_MS)\n`, + : `[@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_NO_FALLBACK=1 or AGENTMEMORY_REMOTE_REQUIRED=1 to fail instead, AGENTMEMORY_FORCE_PROXY=1 to skip the probe, or raise AGENTMEMORY_LIVEZ_TIMEOUT_MS)\n`, ); return false; } diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index 78c5fb23..8b124e60 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -153,6 +153,19 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +type StorageMode = "local" | "remote"; + +function storageModeTextResponse( + payload: unknown, + storageMode: StorageMode, + pretty = false, +): { + content: Array<{ type: string; text: string }>; +} { + if (!isRecord(payload)) return textResponse(payload, pretty); + return textResponse({ ...payload, storage_mode: storageMode }, pretty); +} + function isAuditReceiptRecord(value: unknown): boolean { return isRecord(value) && value["schema"] === "agentmemory.audit.receipt.v1"; } @@ -457,7 +470,7 @@ async function handleProxy( method: "POST", body: JSON.stringify(body), }); - return textResponse(result); + return storageModeTextResponse(result, "remote"); } case "memory_recall": { const body: Record = { @@ -476,7 +489,7 @@ async function handleProxy( method: "POST", body: JSON.stringify(body), }); - return textResponse(result, true); + return storageModeTextResponse(result, "remote", true); } case "memory_smart_search": { const body: Record = { query: v.query, limit: v.limit }; @@ -494,7 +507,7 @@ async function handleProxy( method: "POST", body: JSON.stringify(body), }); - return textResponse(result, true); + return storageModeTextResponse(result, "remote", true); } case "memory_sessions": { const params = new URLSearchParams({ limit: String(v.limit) }); @@ -506,18 +519,18 @@ async function handleProxy( `/agentmemory/sessions?${params.toString()}`, { method: "GET" }, ); - return textResponse(result, true); + return storageModeTextResponse(result, "remote", true); } case "memory_governance_delete": { const result = await handle.call("/agentmemory/governance/memories", { method: "DELETE", body: JSON.stringify({ memoryIds: v.memoryIds, reason: v.reason }), }); - return textResponse(result); + return storageModeTextResponse(result, "remote"); } case "memory_export": { const result = await handle.call("/agentmemory/export", { method: "GET" }); - return textResponse(result, true); + return storageModeTextResponse(result, "remote", true); } case "memory_audit": { const params = new URLSearchParams({ limit: String(v.limit) }); @@ -527,8 +540,9 @@ async function handleProxy( `/agentmemory/audit?${params.toString()}`, { method: "GET" }, ); - return textResponse( + return storageModeTextResponse( v.receipt === true ? enforceAuditReceiptResponse(result) : result, + "remote", true, ); } @@ -565,7 +579,7 @@ async function handleLocal( ...(v.metadata !== undefined && { metadata: v.metadata }), }); kvInstance.persist(); - return textResponse({ saved: id }); + return storageModeTextResponse({ saved: id }, "local"); } case "memory_smart_search": { @@ -573,7 +587,7 @@ async function handleLocal( const format = v.format ?? "compact"; if (format === "compact") { - return textResponse( + return storageModeTextResponse( { mode: format, results: results.map((m) => ({ @@ -586,12 +600,13 @@ async function handleLocal( ...memoryPassthroughFields(m), })), }, + "local", true, ); } if (format === "narrative") { - return textResponse( + return storageModeTextResponse( { mode: format, results: results.map((m) => ({ @@ -604,11 +619,12 @@ async function handleLocal( ...memoryPassthroughFields(m), })), }, + "local", true, ); } - return textResponse( + return storageModeTextResponse( { mode: format, results: results.map((m) => ({ @@ -627,6 +643,7 @@ async function handleLocal( sessionId: firstSessionId(m), })), }, + "local", true, ); } @@ -636,7 +653,7 @@ async function handleLocal( const format = v.format ?? "full"; if (format === "compact") { - return textResponse( + return storageModeTextResponse( { format, results: results.map((m) => ({ @@ -649,6 +666,7 @@ async function handleLocal( ...memoryPassthroughFields(m), })), }, + "local", true, ); } @@ -663,7 +681,7 @@ async function handleLocal( timestamp: m["updatedAt"] || m["createdAt"], ...memoryPassthroughFields(m), })); - return textResponse( + return storageModeTextResponse( { format, results: narrativeResults, @@ -674,11 +692,12 @@ async function handleLocal( ) .join("\n\n"), }, + "local", true, ); } - return textResponse( + return storageModeTextResponse( { format, results: results.map((m) => ({ @@ -697,6 +716,7 @@ async function handleLocal( sessionId: firstSessionId(m), })), }, + "local", true, ); } @@ -708,7 +728,11 @@ async function handleLocal( const filtered = v.timeRange ? filterSessionsByTime(sessions, v.timeRange) : sessions; - return textResponse({ sessions: filtered.slice(0, limit) }, true); + return storageModeTextResponse( + { sessions: filtered.slice(0, limit) }, + "local", + true, + ); } case "memory_governance_delete": { @@ -721,17 +745,24 @@ async function handleLocal( } } kvInstance.persist(); - return textResponse({ - deleted, - requested: (v.memoryIds || []).length, - reason: v.reason, - }); + return storageModeTextResponse( + { + deleted, + requested: (v.memoryIds || []).length, + reason: v.reason, + }, + "local", + ); } case "memory_export": { const memories = await kvInstance.list("mem:memories"); const sessions = await kvInstance.list("mem:sessions"); - return textResponse({ version: VERSION, memories, sessions }, true); + return storageModeTextResponse( + { version: VERSION, memories, sessions }, + "local", + true, + ); } case "memory_audit": { @@ -743,10 +774,11 @@ async function handleLocal( const shaped = v.receipt === true ? filtered.map((entry) => buildAuditReceipt(entry as unknown as AuditEntry)) : filtered; - return textResponse( + return storageModeTextResponse( { entries: shaped.slice(0, limit), }, + "local", true, ); } diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts index 2f701de5..f1a9516d 100644 --- a/test/mcp-standalone-proxy.test.ts +++ b/test/mcp-standalone-proxy.test.ts @@ -58,6 +58,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { delete process.env["AGENTMEMORY_REQUIRE_SERVER"]; delete process.env["AGENTMEMORY_DISABLE_LOCAL_FALLBACK"]; delete process.env["AGENTMEMORY_REMOTE_REQUIRED"]; + delete process.env["AGENTMEMORY_NO_FALLBACK"]; delete process.env["AGENTMEMORY_LIVEZ_TIMEOUT_MS"]; delete process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"]; delete process.env["AGENTMEMORY_REQUIRE_HTTPS"]; @@ -87,6 +88,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const body = JSON.parse(res.content[0].text); expect(body.sessions).toHaveLength(1); expect(body.sessions[0].id).toBe("sess-1"); + expect(body.storage_mode).toBe("remote"); const sessionsCall = calls.find((c) => c.url.includes("/sessions")); expect(sessionsCall?.url).toContain("limit=5"); expect(sessionsCall?.url).toContain("start_time=2026-06-01T00%3A00%3A00Z"); @@ -121,6 +123,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { }); const body = JSON.parse(res.content[0].text); expect(body.query).toBe("auth bug"); + expect(body.storage_mode).toBe("remote"); expect(body.results[0].id).toBe("m1"); expect(smartSearchBody).toMatchObject({ query: "auth bug", @@ -204,6 +207,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { }); const body = JSON.parse(res.content[0].text); expect(body.mode).toBe("full"); + expect(body.storage_mode).toBe("remote"); expect(body.facts[0].id).toBe("m1"); const searchCall = calls.find((c) => c.url.endsWith("/agentmemory/search")); expect(searchCall).toBeDefined(); @@ -258,7 +262,11 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { reason: "cleanup stale test data", }); - expect(JSON.parse(res.content[0].text)).toEqual({ success: true, deleted: 2 }); + expect(JSON.parse(res.content[0].text)).toEqual({ + success: true, + deleted: 2, + storage_mode: "remote", + }); expect(calls).toEqual([ { url: `${BASE}/agentmemory/governance/memories`, @@ -280,6 +288,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const res = await handleToolCall("memory_smart_search", { query: "shape" }, localKv); const body = JSON.parse(res.content[0].text); expect(body).toHaveProperty("mode", "compact"); + expect(body).toHaveProperty("storage_mode", "local"); expect(Array.isArray(body.results)).toBe(true); expect(body.results[0]).toMatchObject({ title: "shape-check entry", @@ -314,6 +323,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const recall = await handleToolCall("memory_recall", { query: "local" }, localKv); const out = JSON.parse(recall.content[0].text); expect(out.format).toBe("full"); + expect(out.storage_mode).toBe("local"); expect(out.results).toHaveLength(1); expect(out.results[0].observation.narrative).toBe("local only"); }); @@ -371,6 +381,35 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { ); }); + it("AGENTMEMORY_NO_FALLBACK=1 rejects livez fallback like AGENTMEMORY_REQUIRE_SERVER", async () => { + process.env["AGENTMEMORY_NO_FALLBACK"] = "1"; + 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; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) { + throw new Error("ECONNREFUSED"); + } + return new Response("unexpected", { status: 200 }); + }); + const localKv = new InMemoryKV(undefined); + await localKv.set("mem:sessions", "ses_local", { id: "ses_local" }); + + try { + await expect(handleToolCall("memory_sessions", {}, localKv)).rejects.toThrow( + /agentmemory server unreachable at http:\/\/localhost:3111; start npx @agentmemory\/agentmemory/, + ); + } finally { + process.stderr.write = origWrite; + } + expect(writes.join("")).toContain( + "local fallback disabled by AGENTMEMORY_NO_FALLBACK=1", + ); + }); + it("AGENTMEMORY_REQUIRE_SERVER=1 rejects proxy call failures instead of local fallback", async () => { process.env["AGENTMEMORY_REQUIRE_SERVER"] = "1"; installFetch((url) => { @@ -391,6 +430,26 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { ); }); + it("AGENTMEMORY_NO_FALLBACK=1 rejects proxy call failures instead of local fallback", async () => { + process.env["AGENTMEMORY_NO_FALLBACK"] = "1"; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.includes("/agentmemory/sessions")) { + return new Response("down", { + status: 503, + statusText: "Service Unavailable", + }); + } + return new Response("unexpected", { status: 200 }); + }); + const localKv = new InMemoryKV(undefined); + await localKv.set("mem:sessions", "ses_local", { id: "ses_local" }); + + await expect(handleToolCall("memory_sessions", {}, localKv)).rejects.toThrow( + /agentmemory server unreachable at http:\/\/localhost:3111; start npx @agentmemory\/agentmemory.*proxy call failed for memory_sessions: GET \/agentmemory\/sessions\?limit=20 -> 503 Service Unavailable/s, + ); + }); + it("invalidates the handle on proxy failure, so the next call re-probes", async () => { let probeCount = 0; let serverUp = true; @@ -439,6 +498,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { }); const body = JSON.parse(res.content[0].text); expect(body.saved).toBe("lesson_xyz"); + expect(body).not.toHaveProperty("storage_mode"); expect(calls).toHaveLength(1); expect(calls[0].body).toEqual({ name: "memory_lesson_save", @@ -512,9 +572,11 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { expect(JSON.parse(exported.content[0].text)).toEqual({ version: "0.9.27", memories: [], + storage_mode: "remote", }); const auditBody = JSON.parse(audit.content[0].text); expect(auditBody.entries).toHaveLength(1); + expect(auditBody.storage_mode).toBe("remote"); expect(auditBody.entries[0]).toMatchObject({ schema: "agentmemory.audit.receipt.v1", operation: "delete", @@ -647,13 +709,14 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { return new Response("not found", { status: 404 }); }); try { - await handleToolCall("memory_save", { + const result = await handleToolCall("memory_save", { content: "force-proxy", sessionId: "ses_proxy", tags: "proxy, audit", external_id: "row-42", metadata: { dataset: "eval-set" }, }); + expect(JSON.parse(result.content[0].text).storage_mode).toBe("remote"); expect(calls.some((c) => c.url.endsWith("/agentmemory/livez"))).toBe(false); const rememberCall = calls.find((c) => c.url.endsWith("/agentmemory/remember")); expect(rememberCall?.body).toEqual({ @@ -697,6 +760,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const saved = JSON.parse(result.content[0].text); expect(saved.saved).toMatch(/^mem_/); + expect(saved.storage_mode).toBe("local"); expect(calls.some((u) => u.endsWith("/agentmemory/livez"))).toBe(false); expect(calls.some((u) => u.endsWith("/agentmemory/remember"))).toBe(true); }); @@ -714,6 +778,7 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { const body = JSON.parse(res.content[0].text); expect(fetchFn).not.toHaveBeenCalled(); + expect(body.storage_mode).toBe("local"); expect(body.results[0].observation.narrative).toBe("local blocked proxy"); }); @@ -814,6 +879,25 @@ describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { ); }); + it("AGENTMEMORY_NO_FALLBACK=1 rejects tools/list proxy failures instead of local tool list", async () => { + process.env["AGENTMEMORY_NO_FALLBACK"] = "1"; + const { handleToolsList } = await import("../src/mcp/standalone.js"); + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/mcp/tools")) { + return new Response("down", { + status: 503, + statusText: "Service Unavailable", + }); + } + return new Response("unexpected", { status: 200 }); + }); + + await expect(handleToolsList()).rejects.toThrow( + /agentmemory server unreachable at http:\/\/localhost:3111; start npx @agentmemory\/agentmemory.*tools\/list proxy failed: GET \/agentmemory\/mcp\/tools -> 503 Service Unavailable/s, + ); + }); + it("falls back to local tools/list when the proxy returns an unexpected shape", async () => { process.env["AGENTMEMORY_DEBUG"] = "1"; const { handleToolsList } = await import("../src/mcp/standalone.js"); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index 01789bbd..6854ad40 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -291,6 +291,9 @@ describe("handleToolCall", () => { ); const parsed = JSON.parse(result.content[0].text); expect(parsed.saved).toMatch(/^mem_/); + expect(parsed.storage_mode).toBe("local"); + const memories = await kv.list>("mem:memories"); + expect(memories[0]).not.toHaveProperty("storage_mode"); expect(writeFileSync).toHaveBeenCalledWith( "/tmp/test-handle.json", expect.any(String), @@ -332,6 +335,7 @@ describe("handleToolCall", () => { ); const parsed = JSON.parse(result.content[0].text); expect(parsed.format).toBe("full"); + expect(parsed.storage_mode).toBe("local"); expect(parsed.results).toHaveLength(1); expect(parsed.results[0].observation.narrative).toBe("TypeScript is great"); }); @@ -354,6 +358,7 @@ describe("handleToolCall", () => { ).content[0].text, ); expect(compact.format).toBe("compact"); + expect(compact.storage_mode).toBe("local"); expect(compact.results[0].title).toContain("Store the release checklist"); expect(compact.results[0]).not.toHaveProperty("content"); @@ -367,6 +372,7 @@ describe("handleToolCall", () => { ).content[0].text, ); expect(narrative.format).toBe("narrative"); + expect(narrative.storage_mode).toBe("local"); expect(narrative.text).toContain("Store the release checklist in memory"); }); @@ -388,6 +394,7 @@ describe("handleToolCall", () => { ).content[0].text, ); expect(compact.mode).toBe("compact"); + expect(compact.storage_mode).toBe("local"); expect(compact.results[0]).toHaveProperty("obsId"); expect(compact.results[0]).not.toHaveProperty("observation"); @@ -401,6 +408,7 @@ describe("handleToolCall", () => { ).content[0].text, ); expect(narrative.mode).toBe("narrative"); + expect(narrative.storage_mode).toBe("local"); expect(narrative.results[0]).toMatchObject({ title: expect.stringContaining("Store the smart search checklist"), narrative: "Store the smart search checklist in memory", @@ -416,6 +424,7 @@ describe("handleToolCall", () => { ).content[0].text, ); expect(full.mode).toBe("full"); + expect(full.storage_mode).toBe("local"); expect(full.results[0].observation.narrative).toBe( "Store the smart search checklist in memory", ); @@ -852,6 +861,7 @@ describe("handleToolCall", () => { ); const parsed = JSON.parse(result.content[0].text); expect(parsed.sessions).toHaveLength(2); + expect(parsed.storage_mode).toBe("local"); }); it("memory_sessions filters local fallback sessions by time range", async () => { @@ -973,6 +983,7 @@ describe("handleToolCall", () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.deleted).toBe(2); expect(parsed.requested).toBe(2); + expect(parsed.storage_mode).toBe("local"); const remaining = await kv.list>("mem:memories"); expect(remaining).toHaveLength(1); @@ -1030,8 +1041,10 @@ describe("handleToolCall", () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.version).toBeDefined(); + expect(parsed.storage_mode).toBe("local"); expect(parsed.memories).toHaveLength(1); expect(parsed.memories[0].content).toBe("export me"); + expect(parsed.memories[0]).not.toHaveProperty("storage_mode"); expect(parsed.sessions).toEqual([{ id: "ses_1", status: "active" }]); }); @@ -1044,6 +1057,7 @@ describe("handleToolCall", () => { const parsed = JSON.parse(result.content[0].text); expect(parsed.entries).toEqual([{ id: "aud_1" }]); + expect(parsed.storage_mode).toBe("local"); }); it("memory_audit returns local privacy-safe receipts when requested", async () => {