mcp_server._get_client has no own-write re-stamp: every add_drawer and check_duplicate reloads the HNSW index (#2307 covered only ChromaBackend)
What happens
#2307 stopped ChromaBackend._client invalidating its cache on the backend's own writes,
by re-stamping the freshness stat once an operation finishes. mcp_server._get_client keys
on the same chroma.sqlite3 mtime but never re-stamps, and _get_collection calls it on
every tool call (v3.9.0 mempalace/mcp_server.py:1604). So after the server's own write,
the next MCP tool that goes through _get_collection sees its own footprint as an external
change, runs _force_chroma_cache_reset() and reopens, and the next vector query reloads
the index from disk.
Search is unaffected: it goes through the backend path that #2307 fixed.
Measured
mempalace 3.9.0 (pip, clean venv), chromadb 1.5.9, Python 3.11, macOS 26 arm64, chroma
backend, a scratch palace of 40 mined notes. One in-process server driven through
handle_request, counting chromadb System creations per call (a new System means the HNSW
index is read from disk again):
| Call | Reloads on 3.9.0 |
|---|---|
status |
0 |
search (first) |
1 (cold open, expected) |
search again |
0 |
check_duplicate |
1 |
search |
0 |
add_drawer |
1 |
search, search |
0 |
add_drawer |
1 |
check_duplicate |
1 |
list_drawers |
0 |
search |
0 |
| (another process mines) | — |
search |
1 (correct: it picks up the new drawers) |
6 reloads in 14 calls. Four of them are the server reacting to its own writes.
Cost per reload is palace-sized. On our 151k-drawer palace a reload takes about 2.5 s (2.9 s for a search that reopens, against 0.2 s warm). That timing is from our own 3.6.0 install; we did not repeat it against 3.9.0 on a palace that size.
Reproduction
# after: pip install mempalace==3.9.0, and a palace with some drawers in wing "seed"
import mempalace.mcp_server as m
from chromadb.api.shared_system_client import SharedSystemClient
created = []
orig = SharedSystemClient._create_system_if_not_exists.__func__
def counting(cls, identifier, settings):
if identifier not in cls._identifier_to_system:
created.append(identifier)
return orig(cls, identifier, settings)
SharedSystemClient._create_system_if_not_exists = classmethod(counting)
def call(tool, args):
before = len(created)
m.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": tool, "arguments": args}})
print(f"{tool:<26} reloads={len(created) - before}")
q = {"query": "whatever is in your palace", "wing": "seed", "limit": 3}
call("mempalace_search", q) # 1, cold open
call("mempalace_search", q) # 0
call("mempalace_add_drawer", {"wing": "probe", "room": "own", "content": "x", "added_by": "p"}) # 1
call("mempalace_check_duplicate", {"content": "x"}) # 1Suggested direction
The same shape as #2307, on the MCP path: after the server's own write completes, and after
_get_collection has opened a collection, re-stat chroma.sqlite3 into _palace_db_inode /
_palace_db_mtime, so a later difference is genuinely another process's write. The same
trade-off #2307 documents applies: an external write landing inside one of the server's own
operations is absorbed until the next change.
If you would rather not rely on mtime at all on this path, the signal we ended up using
downstream is the write-ahead log high-water mark (SELECT max(seq_id) FROM embeddings_queue): reads never move it, and writes from any process always do. That needs
another connection to chroma.sqlite3, so it interacts with #2302 / #2506; on a palace in
WAL mode it should go through whatever _inproc_sqlite ends up being.
What this report does not claim
- Searches on 3.9.0 are fine, and do see other processes' writes.
- Not tested through the hub/proxy path (
mempalace serve), only a stdio server. - The palace here is small; the reload count is the finding, not the timing.
Found while keeping a 3.6.0 install alive on a shared palace; the measurements above are from a clean 3.9.0 venv with no local patches. Investigation and write-up by Claude Opus 5 running in Claude Code on my machine.
Source: MemPalace/mempalace