Skip to content

feat: optional remote ChromaDB support via HttpClient#294

Closed
cypromis wants to merge 29 commits intoMemPalace:mainfrom
cypromis:feat/remote-chromadb
Closed

feat: optional remote ChromaDB support via HttpClient#294
cypromis wants to merge 29 commits intoMemPalace:mainfrom
cypromis:feat/remote-chromadb

Conversation

@cypromis
Copy link
Copy Markdown

@cypromis cypromis commented Apr 8, 2026

Summary

  • Adds a `palace_db` factory module (`get_client` / `get_collection`) as the single ChromaDB access point — returns `HttpClient` when a host is configured, `PersistentClient` otherwise
  • Introduces three new config keys (`chroma_host`, `chroma_port`, `chroma_ssl`) readable from `~/.mempalace/config.json` and env vars (`MEMPALACE_CHROMA_HOST`, `MEMPALACE_CHROMA_PORT`, `MEMPALACE_CHROMA_SSL`), with env vars taking priority
  • Replaces all direct `chromadb.PersistentClient(...)` calls across `miner.py`, `convo_miner.py`, `searcher.py`, `layers.py`, `mcp_server.py`, and `cli.py` with `palace_db.get_client()` / `palace_db.get_collection()`
  • Adds `mempalace remote status` CLI command: prints local/remote mode and — in remote mode — calls `.heartbeat()` to confirm server reachability
  • `HttpClient` instances are cached by `(host, port, ssl)` key to avoid redundant HTTP round-trips on every MCP tool call
  • Zero breaking changes: no host configured = identical behaviour to current upstream

v1 scope and known limitations

This PR targets one developer across multiple workstations. All clients share a single `mempalace_drawers` collection with no per-user namespacing or authentication.

Scenario Works? Notes
1 dev, multiple workstations ✅ Perfect fit Same person's memories sync across machines
Multiple devs, shared team palace ✅ Works All memories pooled — intentional for a shared assistant
Multiple devs, each wanting isolated memory ❌ Not in this version Planned for a follow-up PR

ID collision risks when sharing between multiple users:

  • `mine` / `convo-mine`: IDs hashed from `source_file + chunk_index` — two users with the same file path silently overwrite each other
  • Diary entries: second-resolution timestamp in ID — same-second writes from two users collide
  • Manual `add_drawer`: content-addressed — identical content deduplicates cleanly, different content coexists fine

For single-dev multi-workstation use, collisions are harmless (re-mining the same file is idempotent). Per-user isolation — collection namespacing and optional authentication — is out of scope here and planned for a follow-up PR.

ChromaDB version compatibility

The Docker Compose snippet is pinned to `chromadb/chroma:0.6.3` to match this project's client dependency. ChromaDB 1.0 introduced a breaking change to the collection configuration JSON format (a `_type` field the 0.6.x client does not understand), causing `KeyError: '_type'` when creating or opening collections. Do not use `chromadb/chroma:latest` until MemPalace upgrades its client dependency.

Test plan

  • `pytest tests/` — 127 tests pass, 0 failures
  • `ruff check . && ruff format --check .` — clean
  • `mempalace remote status` with no host → prints `Mode: LOCAL`
  • `MEMPALACE_CHROMA_HOST=localhost mempalace remote status` with `chromadb/chroma:0.6.3` running → prints `Mode: REMOTE` + heartbeat OK
  • Local mine/search unchanged from upstream behaviour

@bgauryy
Copy link
Copy Markdown

bgauryy commented Apr 8, 2026

PR Review: feat: optional remote ChromaDB support via HttpClient

Executive Summary

Aspect Value
PR Goal Add optional remote ChromaDB support via HttpClient, centralizing all DB access through a new palace_db factory module
Files Changed 17
Risk Level 🟡 MEDIUM - Core data-access refactor touching every module that reads/writes ChromaDB
Review Effort 3/5
Recommendation 🔄 REQUEST_CHANGES

Affected Areas: palace_db.py (new), config.py, cli.py, miner.py, convo_miner.py, layers.py, mcp_server.py, searcher.py

Business Impact: Enables multi-machine setups (e.g. mine on a laptop, serve via MCP on a headless server). Zero behavioral change for existing local users.

Flow Changes: Every ChromaDB access now routes through palace_db.get_client() / palace_db.get_collection(). HttpClient instances are cached; PersistentClient instances are not (regression — see #1 below).

Ratings

Aspect Score
Correctness 3/5
Security 5/5
Performance 3/5
Maintainability 4/5

PR Health

  • Has clear description
  • References ticket/issue (if applicable)
  • Appropriate size (or justified if large)
  • Has relevant tests

High Priority Issues

#1: PersistentClient created on every call — performance regression for MCP server

Location: mempalace/palace_db.py:28-33 | Confidence: ✅ HIGH

_http_clients caches HttpClient instances, but PersistentClient has no equivalent cache. Every call to get_client() in local mode creates a new chromadb.PersistentClient(path=path). The MCP server previously cached both the client and the collection in module-level globals (_client_cache, _collection_cache); this PR removes that caching without replacing it. For a long-running MCP server handling dozens of tool calls, this is a measurable perf regression.

- path = palace_path or cfg.palace_path
- os.makedirs(path, exist_ok=True)
- return chromadb.PersistentClient(path=path)
+ _persistent_clients = {}  # cache: path -> PersistentClient
+
+ ...
+
+ path = palace_path or cfg.palace_path
+ if path not in _persistent_clients:
+     os.makedirs(path, exist_ok=True)
+     _persistent_clients[path] = chromadb.PersistentClient(path=path)
+ return _persistent_clients[path]

🔗 #2: Incomplete migration — palace_graph.py still imports chromadb directly

Location: mempalace/palace_graph.py:21 | Confidence: ✅ HIGH

palace_graph.py on main still has import chromadb and calls chromadb.PersistentClient() directly in _get_collection(). This module is not touched by the PR, so graph traversal and tunnel queries bypass the new factory entirely and will always use local mode — even when the user has configured a remote ChromaDB host.

- import chromadb
+ from . import palace_db

  def _get_collection(config=None):
      config = config or MempalaceConfig()
      try:
-         client = chromadb.PersistentClient(path=config.palace_path)
-         return client.get_collection(config.collection_name)
+         return palace_db.get_collection(palace_path=config.palace_path, name=config.collection_name)
      except Exception:
          return None

Medium Priority Issues

🏗️ #3: Dead global variables in mcp_server.py

Location: mempalace/mcp_server.py_get_collection() | Confidence: ✅ HIGH

_client_cache and _collection_cache are still declared via global but are never assigned or read. They should be removed to avoid confusion.

  def _get_collection(create=False):
-     global _client_cache, _collection_cache
      try:
          if create:
              return palace_db.get_collection(

🐛 #4: cmd_remote subparser status subcommand is registered but never dispatched

Location: mempalace/cli.py:530-533 | Confidence: ⚠️ MED

p_remote_sub is created with a status subparser, but cmd_remote ignores args.remote_cmd entirely — it always runs the status logic. If a user types mempalace remote (without status), it works, but mempalace remote status also works only because the sub-command is silently ignored. Either remove the subparser or dispatch on args.remote_cmd for future extensibility (e.g. mempalace remote configure).

  def cmd_remote(args):
      """Show remote/local ChromaDB mode and test connectivity if remote."""
+     if getattr(args, "remote_cmd", None) and args.remote_cmd != "status":
+         print(f"  Unknown remote command: {args.remote_cmd}")
+         return
      cfg = MempalaceConfig()

Or simpler — remove the subparser entirely since there's only one action.


Low Priority Issues

🎨 #5: DEFAULT_COLLECTION re-exported under a different name

Location: mempalace/palace_db.py:13 | Confidence: ⚠️ MED

from .config import DEFAULT_COLLECTION_NAME as DEFAULT_COLLECTION renames the constant. Callers now use palace_db.DEFAULT_COLLECTION while config.py exports DEFAULT_COLLECTION_NAME. This creates two names for the same thing. Consider keeping the canonical name or adding a re-export note.


🎨 #6: Missing sys import in cmd_remote

Location: mempalace/cli.py:389 | Confidence: ✅ HIGH

cmd_remote calls sys.exit(1) but sys is only imported at module level if the existing code already does so. Verify sys is imported at the top of cli.py — if not, this will crash on unreachable remote servers.


Flow Impact Analysis

Before PR:
  miner.py ──→ chromadb.PersistentClient ──→ ChromaDB (local only)
  layers.py ──→ chromadb.PersistentClient ──→ ChromaDB (local only)
  searcher.py ──→ chromadb.PersistentClient ──→ ChromaDB (local only)
  mcp_server.py ──→ chromadb.PersistentClient (cached) ──→ ChromaDB (local only)
  palace_graph.py ──→ chromadb.PersistentClient ──→ ChromaDB (local only)

After PR:
  miner.py ──→ palace_db.get_client/get_collection ──→ HttpClient OR PersistentClient
  layers.py ──→ palace_db.get_collection ──→ HttpClient OR PersistentClient
  searcher.py ──→ palace_db.get_client ──→ HttpClient OR PersistentClient
  mcp_server.py ──→ palace_db (no caching!) ──→ HttpClient OR PersistentClient
  palace_graph.py ──→ chromadb.PersistentClient ──→ ChromaDB (STILL LOCAL ONLY — missed)

Created by Octocode MCP https://octocode.ai

@cypromis
Copy link
Copy Markdown
Author

cypromis commented Apr 8, 2026

Thanks for the review. Addressing each point:

#1 — PersistentClient not cached (fixed): Confirmed regression. Added _persistent_clients = {} cache in palace_db.py mirroring the existing _http_clients cache. Both client types are now cached by their lookup key.

#2palace_graph.py not migrated (out of scope): palace_graph.py is explicitly excluded from this PR's scope (CLAUDE.md: "Do not touch AAAK, knowledge_graph.py, palace_graph.py, or dialect.py — those are out of scope"). Agreed this is a gap — it should be a follow-up PR. Noted in the PR description's known limitations.

#3 — Dead globals in mcp_server.py (fixed): Removed _client_cache = None, _collection_cache = None, and the global declaration from _get_collection. These were leftover from before the factory migration.

#4 — Subparser dispatch (fixed): Added a guard in cmd_remote that rejects unknown subcommands, making the subparser meaningful rather than decorative. Kept the subparser itself since mempalace remote status is documented and tested.

#5DEFAULT_COLLECTION alias: The rename is intentional — palace_db.DEFAULT_COLLECTION is the public-facing name callers use; DEFAULT_COLLECTION_NAME stays the canonical name in config.py. No change.

#6 — Missing sys import: False positive — sys is already imported at cli.py:30.

@cypromis cypromis force-pushed the feat/remote-chromadb branch from 05c54d0 to facb351 Compare April 9, 2026 00:25
Copy link
Copy Markdown

@web3guru888 web3guru888 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice refactor — the palace_db factory is clean, well-tested, and the migration is thorough. A few observations:

1. Graph tool descriptions are stale after palace_graph.py migration

The MCP tool descriptions for mempalace_traverse, mempalace_find_tunnels, and mempalace_graph_stats were annotated with "reads from local palace only" (commit 8b92791), but palace_graph.py was subsequently migrated to use palace_db.get_collection() (commit 09d070b). Since the graph tools now go through the factory and respect remote mode, the "local only" caveat in the descriptions is inaccurate. Should be easy to remove.

2. get_or_create_collection called on every get_collection() — remote mode overhead

In remote mode, palace_db.get_collection() calls client.get_or_create_collection() which is an HTTP round-trip on every invocation. The client is cached but the collection object is not. For the MCP server processing many tool calls, this adds an HTTP call per dispatch. For v1 this is probably fine (the benchmark shows ~354µs locally), but for remote mode over network it could add up. A collection cache (similar to the client cache) would help.

3. uv.lock diff includes unrelated packages

The lock file changes include autocorrect and coverage — looks like a full uv lock sync that pulled in transitive deps unrelated to this PR. Not harmful but adds ~250 lines of noise. Consider regenerating the lockfile in a separate commit or PR to keep the diff focused.

4. ChromaDB version compatibility note

The PR correctly pins Docker to chromadb/chroma:0.6.3 to match the current chromadb>=0.5.0,<0.7 dependency. ChromaDB stable is now 1.5.5 and mixing 0.6.x client with 1.x server causes KeyError: '_type' — the README warning about this is good. Might be worth making the version constraint even more prominent since users' first instinct will be docker pull chromadb/chroma:latest.

Overall: solid work. The factory pattern is the right abstraction, the test coverage is good, and the README documentation is thorough. The issues above are minor and shouldn't block merge.

cypromis added 24 commits April 9, 2026 20:29
Add three new properties to MempalaceConfig supporting optional remote
ChromaDB configuration: chroma_host (None = local mode), chroma_port
(default 8000), and chroma_ssl (default False). All three support env
var overrides (MEMPALACE_CHROMA_HOST/PORT/SSL) at higher priority than
config file values. Empty string host env var is treated as absent.
Previously, a non-integer value in MEMPALACE_CHROMA_PORT was silently
swallowed and fell back to the config file value, giving no indication
that the env var was being ignored. Now raises ValueError with a clear
message including the offending value.

Adds a corresponding test to tests/test_palace_db.py to assert the
error is raised and the message names the env var.
Introduces mempalace/palace_db.py as the central ChromaDB access point.
get_client() returns HttpClient when chroma_host is configured, PersistentClient
otherwise. get_collection() wraps get_or_create_collection for convenience.
Exposes palace_db from the package and adds 4 factory tests (9 total in test_palace_db.py).

Note: chromadb.PersistentClient is a factory function not a class, so tests
assert on client.get_settings().is_persistent rather than isinstance checks.
Replace every direct chromadb.PersistentClient(path=...) call in
production code with palace_db.get_client() / palace_db.get_collection(),
so remote HttpClient support is automatically picked up wherever ChromaDB
is accessed.

Files changed:
- mempalace/convo_miner.py: get_collection() → palace_db.get_collection()
- mempalace/miner.py: get_collection() + status() → palace_db
- mempalace/searcher.py: search() + search_memories() → palace_db.get_client()
- mempalace/layers.py: all five layer classes → palace_db.get_client()
- mempalace/mcp_server.py: _get_collection() → palace_db (palace_path
  threaded through so monkeypatched _config is respected in tests)
- mempalace/cli.py: cmd_repair() + cmd_compress() → palace_db.get_client()

Orphaned `import chromadb` lines removed from each migrated file.
palace_graph.py and all test files are untouched.
Move `from . import palace_db` out of function bodies (_get_collection,
cmd_repair, cmd_compress) and into the module-level import block of each
file, following standard Python import hygiene.
Adds cmd_remote() handler and 'remote status' subcommand to the CLI.
In local mode (no chroma_host), prints mode and palace path. In remote
mode, calls client.heartbeat() to verify connectivity, exits 1 if
unreachable. Adds test_remote_status_local_mode test to test_palace_db.py.
…e local-only

Replace all five palace_db.get_client().get_collection("mempalace_drawers")
chains in layers.py with palace_db.get_collection(), routing through the
DEFAULT_COLLECTION constant and supporting remote mode correctly.

Add a note to the Remote Mode section in README.md clarifying that graph
traversal MCP tools (mempalace_traverse, mempalace_find_tunnels,
mempalace_graph_stats) operate in local mode only.
…fix remaining string literals

- palace_db.py: remove duplicate DEFAULT_COLLECTION string; import
  DEFAULT_COLLECTION_NAME from config.py as DEFAULT_COLLECTION so both
  names resolve to the same object
- palace_db.py: add _http_clients dict cache keyed by (host, port, ssl)
  to avoid repeated HTTP connection overhead in long-lived processes
  (e.g. MCP server)
- miner.py, searcher.py, cli.py: replace all hardcoded "mempalace_drawers"
  string literals at call sites with palace_db.DEFAULT_COLLECTION; only
  config.py retains the canonical definition
- tests/test_palace_db.py: move mid-file imports (unittest.mock, chromadb,
  palace_db, subprocess, sys) to top of file; clear _http_clients cache
  in HttpClient test to ensure mock is always invoked
All ChromaDB access routes through two functions:
- get_client(): returns HttpClient when chroma_host is configured,
  PersistentClient otherwise. Both are cached at module level so
  long-lived processes (MCP server) pay connection cost only once.
- get_collection(): wraps get_or_create_collection via get_client().
- clear_caches(): explicit cache invalidation for test teardown and
  config reloads in long-running processes.

palace_path is ignored in remote mode — the server manages storage.
…e_db

palace_graph._get_collection() was calling chromadb.PersistentClient()
directly, bypassing the new factory. In remote mode this caused a
split-brain: graph MCP tools always read local data even when the rest
of the system was configured to use a remote ChromaDB server.

Replaced direct instantiation with palace_db.get_collection(), which
honours the active config (local or remote) transparently.
- Remove _client_cache and _collection_cache module-level variables and
  the matching 'global' statement in _get_collection(). These were never
  populated after the palace_db refactor — actual caching now lives in
  palace_db._persistent_clients / _http_clients.
- Fix hashlib.md5() call to pass usedforsecurity=False so the code does
  not raise ValueError on FIPS-enabled hosts (drawer ID generation only,
  not a security-sensitive use).
- Annotate graph tool descriptions to note they read from local palace
  only — remote data is not included if remote mode is configured.
cmd_remote() had no guard for unrecognised subcommands — an unknown
argument would silently fall through. Added an explicit check that
prints to stderr and returns early, so callers (scripts, CI) get a
non-zero exit code and a clear error message.
The existing autouse fixture reset mcp_server._client_cache and
_collection_cache — attributes that no longer exist after the palace_db
refactor. As a result, the real caches (palace_db._persistent_clients
and _http_clients) were never cleared between tests, allowing client
state to leak across test cases.

Replaced with _reset_palace_db_caches which calls palace_db.clear_caches()
before and after each test, restoring proper isolation.
…tests

- test_clear_caches_empties_both_caches: verifies clear_caches() drains
  both _persistent_clients and _http_clients caches.
- test_remote_status_unknown_subcommand_exits_nonzero: ensures 'mempalace
  remote badcmd' exits non-zero and prints the unknown command to stderr.
- test_palace_graph_routes_through_palace_db: asserts that
  palace_graph._get_collection() calls palace_db.get_collection() and
  does not bypass the factory with a direct chromadb instantiation.
actions/checkout@v6 and actions/setup-python@v6 do not exist — the
latest stable releases are @v4 and @v5 respectively. Using @v6 caused
the CI workflow to fail on every run with 'unable to resolve action'.
cypromis added 5 commits April 9, 2026 20:36
The bump-plugin-version workflow was triggered by push to main with no
guard, meaning it could re-trigger itself if run with a PAT (which
re-fires push events). Added a job-level condition:

  if: "!startsWith(github.event.head_commit.message, 'chore: bump version')"

so the workflow skips commits it generated itself, preventing an
infinite trigger loop.
ChromaDB 0.6.x ships with no authentication. The Docker compose snippet
in the Remote Mode section binds port 8000 to all interfaces by default,
giving anyone who can reach that port unrestricted read/write access to
the user's memories.

Added an explicit callout warning users to:
- restrict binding to localhost or a trusted interface
- use a firewall or VPN for multi-machine setups
- never expose port 8000 to the public internet
Measures the five latency hotspots identified in the code review:
1. MempalaceConfig() instantiation cost (4–20 µs per get_client() call)
2. get_client() cold vs warm — 98× speedup on cache hit (446 µs → 5 µs)
3. get_collection() overhead — 354 µs per MCP dispatch
4. 4× independent col.get() vs 1 shared fetch — 4× speedup (2.9 ms → 728 µs)
5. MCP server hotpath simulation

Run with: python benchmarks/factory_bench.py [--palace /path]
TODO.md: tracks two follow-up items deferred from this PR —
caching MempalaceConfig() in palace_db.get_client() and per-user
collection namespacing for remote multi-user setups.

CLAUDE.md: remove palace_graph.py from the 'do not touch' constraint
now that it has been migrated to use the palace_db factory.
@cypromis cypromis force-pushed the feat/remote-chromadb branch from f829d76 to 72a384f Compare April 9, 2026 17:36
@bensig
Copy link
Copy Markdown
Collaborator

bensig commented Apr 11, 2026

Conflicts with main. Remote ChromaDB and backend abstraction are being discussed in #413 and #574. We'll pick one direction — a focused rebase after that decision would be welcome.

@bensig bensig closed this Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants