Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).

<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.3.3-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.3.4-4dc9f6?style=flat-square&labelColor=0a0e14
[release-link]: https://github.com/MemPalace/mempalace/releases
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
[python-link]: https://www.python.org/
Expand Down
80 changes: 62 additions & 18 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,11 +587,13 @@ def tool_search(
max_distance: float = 1.5,
min_similarity: float = None,
context: str = None,
tenant_id: str = None,
):
limit = max(1, min(limit, _MAX_RESULTS))
try:
wing = _sanitize_optional_name(wing, "wing")
room = _sanitize_optional_name(room, "room")
tenant_id = _sanitize_optional_name(tenant_id, "tenant_id")
except ValueError as e:
return {"error": str(e)}
# Backwards compat: accept old name
Expand All @@ -613,6 +615,7 @@ def tool_search(
n_results=limit,
max_distance=dist,
vector_disabled=_vector_disabled,
tenant_id=tenant_id,
)
if _vector_disabled:
result["vector_disabled"] = True
Expand Down Expand Up @@ -646,7 +649,7 @@ def tool_check_duplicate(content: str, threshold: float = 0.9):
"vector_disabled": True,
"vector_disabled_reason": _vector_disabled_reason,
"hint": (
"duplicate detection requires vector search; run " "`mempalace repair` to restore"
"duplicate detection requires vector search; run `mempalace repair` to restore"
),
}
try:
Expand Down Expand Up @@ -780,31 +783,54 @@ def tool_follow_tunnels(wing: str, room: str):


def tool_add_drawer(
wing: str, room: str, content: str, source_file: str = None, added_by: str = "mcp"
wing: str,
room: str,
content: str,
source_file: str = None,
added_by: str = "mcp",
tenant_id: str = None,
):
"""File verbatim content into a wing/room. Checks for duplicates first."""
"""File verbatim content into a wing/room. Checks for duplicates first.

When ``tenant_id`` is supplied:
- it's persisted on the drawer's chromadb metadata so a
tenant-scoped ``mempalace_search`` can filter on it;
- it's mixed into the drawer-id hash so two tenants writing the
same (wing, room, content) get distinct drawers instead of
colliding on the existing un-tenanted hash.

Calls without ``tenant_id`` keep the legacy hash and write no
tenant metadata — pre-multitenant deployments are bit-compatible.
"""
global _metadata_cache
try:
wing = sanitize_name(wing, "wing")
room = sanitize_name(room, "room")
content = sanitize_content(content)
if tenant_id is not None:
tenant_id = sanitize_name(tenant_id, "tenant_id")
except ValueError as e:
return {"success": False, "error": str(e)}

col = _get_collection(create=True)
if not col:
return _no_palace()

drawer_id = (
f"drawer_{wing}_{room}_{hashlib.sha256((wing + room + content).encode()).hexdigest()[:24]}"
)
if tenant_id:
# Salt the hash with tenant_id so cross-tenant collisions of
# identical (wing, room, content) don't overwrite each other.
hash_input = f"{tenant_id}\x00{wing}{room}{content}".encode()
else:
hash_input = (wing + room + content).encode()
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256(hash_input).hexdigest()[:24]}"

_wal_log(
"add_drawer",
{
"drawer_id": drawer_id,
"wing": wing,
"room": room,
"tenant_id": tenant_id,
"added_by": added_by,
"content_length": len(content),
"content_preview": content[:200],
Expand All @@ -820,23 +846,33 @@ def tool_add_drawer(
pass

try:
metadata = {
"wing": wing,
"room": room,
"source_file": source_file or "",
"chunk_index": 0,
"added_by": added_by,
"filed_at": datetime.now().isoformat(),
}
if tenant_id:
metadata["tenant_id"] = tenant_id
col.upsert(
ids=[drawer_id],
documents=[content],
metadatas=[
{
"wing": wing,
"room": room,
"source_file": source_file or "",
"chunk_index": 0,
"added_by": added_by,
"filed_at": datetime.now().isoformat(),
}
],
metadatas=[metadata],
)
_metadata_cache = None
logger.info(f"Filed drawer: {drawer_id} → {wing}/{room}")
return {"success": True, "drawer_id": drawer_id, "wing": wing, "room": room}
logger.info(
f"Filed drawer: {drawer_id} → {wing}/{room}"
+ (f" tenant={tenant_id}" if tenant_id else "")
)
return {
"success": True,
"drawer_id": drawer_id,
"wing": wing,
"room": room,
**({"tenant_id": tenant_id} if tenant_id else {}),
}
except Exception as e:
return {"success": False, "error": str(e)}

Expand Down Expand Up @@ -1586,6 +1622,10 @@ def tool_reconnect():
"type": "string",
"description": "Background context for the search (optional). NOT used for embedding — only for future re-ranking.",
},
"tenant_id": {
"type": "string",
"description": "Per-tenant scope (optional). When set, restrict results to drawers whose metadata tenant_id matches. Drawers stored without a tenant_id are excluded from a tenant-scoped query.",
},
},
"required": ["query"],
},
Expand Down Expand Up @@ -1622,6 +1662,10 @@ def tool_reconnect():
},
"source_file": {"type": "string", "description": "Where this came from (optional)"},
"added_by": {"type": "string", "description": "Who is filing this (default: mcp)"},
"tenant_id": {
"type": "string",
"description": "Per-tenant scope (optional). When set, persisted in metadata and salted into drawer-id so two tenants storing identical content get distinct drawers. Required if the host enforces strict tenant scoping.",
},
},
"required": ["wing", "room", "content"],
},
Expand Down
59 changes: 44 additions & 15 deletions mempalace/searcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,32 @@ def _hybrid_rank(
return results


def build_where_filter(wing: str = None, room: str = None) -> dict:
"""Build ChromaDB where filter for wing/room filtering."""
if wing and room:
return {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
return {"wing": wing}
elif room:
return {"room": room}
return {}
def build_where_filter(
wing: str = None,
room: str = None,
tenant_id: str = None,
) -> dict:
"""Build ChromaDB where filter for wing/room/tenant scoping.

tenant_id, when set, scopes the query to drawers whose metadata
matches. Drawers written before tenant_id support landed have no
such metadata field and are excluded from a tenant-scoped query —
callers running a multi-tenant deployment should backfill or
re-index those entries before relying on per-tenant isolation.
"""
clauses = []
if wing:
clauses.append({"wing": wing})
if room:
clauses.append({"room": room})
if tenant_id:
clauses.append({"tenant_id": tenant_id})

if not clauses:
return {}
if len(clauses) == 1:
return clauses[0]
return {"$and": clauses}


def _extract_drawer_ids_from_closet(closet_doc: str) -> list:
Expand Down Expand Up @@ -372,6 +389,7 @@ def _bm25_only_via_sqlite(
room: str = None,
n_results: int = 5,
max_candidates: int = 500,
tenant_id: str = None,
) -> dict:
"""BM25-only search reading drawers directly from chroma.sqlite3.

Expand Down Expand Up @@ -473,7 +491,7 @@ def _bm25_only_via_sqlite(
if not candidate_ids:
return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "tenant_id": tenant_id},
"total_before_filter": 0,
"results": [],
"fallback": "bm25_only_via_sqlite",
Expand All @@ -500,15 +518,17 @@ def _bm25_only_via_sqlite(
else:
d["metadata"][key] = sval if sval is not None else ival

# Apply wing/room filters in Python (FTS5 candidates may include
# entries from other wings).
# Apply wing/room/tenant filters in Python (FTS5 candidates may
# include entries from other wings or other tenants).
candidates = []
for d in drawers.values():
meta = d["metadata"]
if wing and meta.get("wing") != wing:
continue
if room and meta.get("room") != room:
continue
if tenant_id and meta.get("tenant_id") != tenant_id:
continue
candidates.append(
{
"text": d["text"],
Expand Down Expand Up @@ -537,7 +557,7 @@ def _bm25_only_via_sqlite(

return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "tenant_id": tenant_id},
"total_before_filter": len(candidates),
"results": hits,
"fallback": "bm25_only_via_sqlite",
Expand All @@ -553,6 +573,7 @@ def search_memories(
n_results: int = 5,
max_distance: float = 0.0,
vector_disabled: bool = False,
tenant_id: str = None,
) -> dict:
"""Programmatic search — returns a dict instead of printing.

Expand All @@ -572,6 +593,13 @@ def search_memories(
(#1222). Set by the MCP server when the HNSW capacity probe
detects a divergence that would segfault chromadb on segment
load.
tenant_id: Optional per-tenant scope. When supplied, drawers whose
metadata's ``tenant_id`` does not match are filtered out at the
chromadb (or sqlite-fallback) layer. Drawers stored before the
field existed have no value and will be excluded from a
tenant-scoped query — callers in shared-palace deployments
must either backfill metadata or rely on wing/room scoping
until backfill lands.
"""
if vector_disabled:
return _bm25_only_via_sqlite(
Expand All @@ -580,6 +608,7 @@ def search_memories(
wing=wing,
room=room,
n_results=n_results,
tenant_id=tenant_id,
)

try:
Expand All @@ -591,7 +620,7 @@ def search_memories(
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
}

where = build_where_filter(wing, room)
where = build_where_filter(wing, room, tenant_id)

# Hybrid retrieval: always query drawers directly (the floor), then use
# closet hits to boost rankings. Closets are a ranking SIGNAL, never a
Expand Down Expand Up @@ -757,7 +786,7 @@ def search_memories(

return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "tenant_id": tenant_id},
"total_before_filter": len(_first_or_empty(drawer_results, "documents")),
"results": hits,
}
2 changes: 1 addition & 1 deletion mempalace/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""

__version__ = "3.3.3"
__version__ = "3.3.4"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.3.3"
version = "3.3.4"
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
65 changes: 62 additions & 3 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,9 +476,68 @@ def test_add_drawer_shared_header_no_collision(self, monkeypatch, config, palace

assert result1["success"] is True
assert result2["success"] is True
assert (
result1["drawer_id"] != result2["drawer_id"]
), "Documents with shared header but different content must have distinct drawer IDs"
assert result1["drawer_id"] != result2["drawer_id"], (
"Documents with shared header but different content must have distinct drawer IDs"
)

def test_add_drawer_with_tenant_id_persists_metadata(
self, monkeypatch, config, palace_path, kg
):
"""tenant_id, when supplied, must round-trip into chromadb metadata."""
_patch_mcp_server(monkeypatch, config, kg)
_client, col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer

result = tool_add_drawer(
wing="multitenant",
room="r",
content="tenant-A's notes about Rust lifetimes.",
tenant_id="tenant-a",
)
assert result["success"] is True
assert result["tenant_id"] == "tenant-a"
row = col.get(ids=[result["drawer_id"]], include=["metadatas"])
assert row["metadatas"][0]["tenant_id"] == "tenant-a"

def test_add_drawer_tenant_isolation_breaks_cross_tenant_dedup(
self, monkeypatch, config, palace_path, kg
):
"""Two tenants writing the SAME (wing, room, content) must get distinct drawers."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer

content = "Shared topic — both tenants happen to file the same observation."
a = tool_add_drawer(wing="w", room="r", content=content, tenant_id="tenant-a")
b = tool_add_drawer(wing="w", room="r", content=content, tenant_id="tenant-b")
assert a["success"] is True
assert b["success"] is True
assert a["drawer_id"] != b["drawer_id"], (
"Tenant-A's drawer and tenant-B's drawer must not collide; the "
"drawer-id hash must be salted with tenant_id."
)

def test_add_drawer_no_tenant_keeps_legacy_hash(self, monkeypatch, config, palace_path, kg):
"""Calls without tenant_id must keep the pre-3.3.4 deterministic id (bit-compat)."""
import hashlib

_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer

wing, room = "legacy", "shape"
content = "Pre-3.3.4 callers must continue to get the un-salted drawer id."
expected = (
f"drawer_{wing}_{room}_"
+ hashlib.sha256((wing + room + content).encode()).hexdigest()[:24]
)
result = tool_add_drawer(wing=wing, room=room, content=content)
assert result["success"] is True
assert result["drawer_id"] == expected
assert "tenant_id" not in result

def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
Expand Down
Loading
Loading