Summary
Every project-scoped MCP tool takes a project argument, but nothing on the MCP surface ever tells the model what the current project is. The model has to guess — and when it guesses wrong, the memory is silently filed under someone else's project.
resolveProject() exists and does exactly the right thing, but it's only wired into src/hooks/*. The MCP path never calls it.
Why it matters
This affects the 16 of 22 adapters that are MCP-only (cursor, zed, kiro, warp, cline, continue, opencode, qwen, …) — none of them have hooks, so MCP is their only path and every memory they save is scoped by a guess.
Today it's partly masked: the model doesn't scope its searches either, so recall runs unscoped and still finds things. The failure mode is cross-project bleed as a user's memory store grows, not an immediately visible error.
Reproduction
Observed while wiring Klaat Code (#1222), in a fresh /private/tmp/memproj git repo:
- Start the server, connect an MCP host, ask the agent to save a fact.
- The agent calls
memory_save. The hook path resolves the project correctly:
[agentmemory] info No context available {"project":"memproj"} <- hooks: correct
[agentmemory] info Memory saved {"memId":"mem_…","project":"okahu-pytest-demo"} <- MCP: wrong
okahu-pytest-demo is an unrelated directory elsewhere on the machine. The memory was saved from a session in memproj and filed under a project it has nothing to do with.
Confirmed the model supplied it, not the server: mem::remember (src/functions/remember.ts:66-69) only stores project when explicitly passed, and a manual memory_save call with no project argument produces a memory with no project field at all.
Root cause
memory_save's schema (src/mcp/tools-registry.ts:78-84) says:
"Stable canonical project identifier this memory belongs to … Must match the value used when the session was started. Do not use filesystem paths or ad-hoc display names — those change across machines and will silently break project scoping."
The instruction is right, but it's addressed to a model that was never told the value. Nothing in the tool list, the server info, or the guideline written by connect carries it.
Suggested fix
The shim already knows the answer and doesn't use it. stdio MCP hosts spawn the server with cwd set to the project directory — Klaat Code does cwd: process.cwd(), and the others follow the same convention — so resolveProject(process.cwd()) inside the shim yields the correct project.
Defaulting project to that when the model omits it would fix scoping for every stdio host at once, with no per-adapter work. Two caveats worth designing around:
- Transport guard.
cwd is meaningless for remote/HTTP servers, so this should apply to stdio only.
- Don't override. Only fill the field when absent, so an explicit value from the model or a multi-project host still wins.
The cleaner protocol-level answer would be for the shim to request the client's workspace roots during the handshake. I'd avoid that for now: at least one host advertises roots without implementing it (KlaatAI/klaatcode#59), so a roots/list request hangs until timeout rather than failing fast.
Happy to send a PR if you're open to the cwd-based approach.
Summary
Every project-scoped MCP tool takes a
projectargument, but nothing on the MCP surface ever tells the model what the current project is. The model has to guess — and when it guesses wrong, the memory is silently filed under someone else's project.resolveProject()exists and does exactly the right thing, but it's only wired intosrc/hooks/*. The MCP path never calls it.Why it matters
This affects the 16 of 22 adapters that are MCP-only (cursor, zed, kiro, warp, cline, continue, opencode, qwen, …) — none of them have hooks, so MCP is their only path and every memory they save is scoped by a guess.
Today it's partly masked: the model doesn't scope its searches either, so recall runs unscoped and still finds things. The failure mode is cross-project bleed as a user's memory store grows, not an immediately visible error.
Reproduction
Observed while wiring Klaat Code (#1222), in a fresh
/private/tmp/memprojgit repo:memory_save. The hook path resolves the project correctly:okahu-pytest-demois an unrelated directory elsewhere on the machine. The memory was saved from a session inmemprojand filed under a project it has nothing to do with.Confirmed the model supplied it, not the server:
mem::remember(src/functions/remember.ts:66-69) only storesprojectwhen explicitly passed, and a manualmemory_savecall with noprojectargument produces a memory with no project field at all.Root cause
memory_save's schema (src/mcp/tools-registry.ts:78-84) says:The instruction is right, but it's addressed to a model that was never told the value. Nothing in the tool list, the server info, or the guideline written by
connectcarries it.Suggested fix
The shim already knows the answer and doesn't use it. stdio MCP hosts spawn the server with
cwdset to the project directory — Klaat Code doescwd: process.cwd(), and the others follow the same convention — soresolveProject(process.cwd())inside the shim yields the correct project.Defaulting
projectto that when the model omits it would fix scoping for every stdio host at once, with no per-adapter work. Two caveats worth designing around:cwdis meaningless for remote/HTTP servers, so this should apply to stdio only.The cleaner protocol-level answer would be for the shim to request the client's workspace
rootsduring the handshake. I'd avoid that for now: at least one host advertisesrootswithout implementing it (KlaatAI/klaatcode#59), so aroots/listrequest hangs until timeout rather than failing fast.Happy to send a PR if you're open to the cwd-based approach.