Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

5344 lines
214 KiB
Python

"""
test_mcp_server.py — Tests for the MCP server tool handlers and dispatch.
Tests each tool handler directly (unit-level) and the handle_request
dispatch layer (integration-level). Uses isolated palace + KG fixtures
via monkeypatch to avoid touching real data.
"""
from datetime import datetime
import json
import os
from types import SimpleNamespace
import subprocess
import sys
from unittest.mock import MagicMock
import pytest
from _chroma_palace_helper import make_minimal_chroma_sqlite
# ── MCP entry point: PYTHONPATH stripping ────────────────────────────────
_MCP_LEAK_PREFIX = "/__mempalace_mcp_leak_sentinel__"
def test_mcp_main_strips_leaked_pythonpath_from_env():
"""mempalace.mcp_server:main must drop PYTHONPATH from the process env
so any subprocess this server spawns starts clean. Mirrors the
sys.path-filter test in test_init.py but for the env half of the
split fix. See #1423.
Three assertions cover the full split contract:
- ENV_MID (after import, before main) is preserved verbatim:
regression detector for someone moving the env pop back into
__init__.py.
- SENTINEL_IN_PATH is False at import time: package-level sys.path
filter half of the split actually ran.
- ENV_AFTER (after main) is None: MCP entry-point env strip ran.
The main loop reads JSON-RPC lines from stdin until EOF; closing
stdin makes readline() return '' and exits the loop cleanly, which
lets us observe the post-main env state. Probes go to stderr because
mcp_server redirects stdout at import time for clean JSON-RPC."""
expected_env = f"{_MCP_LEAK_PREFIX}/a{os.pathsep}{_MCP_LEAK_PREFIX}/b"
env = os.environ.copy()
env["PYTHONPATH"] = expected_env
code = (
"import os, sys\n"
"from mempalace.mcp_server import main\n"
f"prefix = {_MCP_LEAK_PREFIX!r}\n"
"sys.stderr.write('ENV_MID: ' + repr(os.environ.get('PYTHONPATH')) + '\\n')\n"
"sys.stderr.write('SENTINEL_IN_PATH: ' + repr(any(prefix in (p or '') for p in sys.path)) + '\\n')\n"
"main()\n"
"sys.stderr.write('ENV_AFTER: ' + repr(os.environ.get('PYTHONPATH')) + '\\n')\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
env=env,
input="", # empty stdin → readline() returns '' → loop breaks
capture_output=True,
text=True,
check=False,
timeout=30,
)
diag = f"rc={result.returncode}; stdout={result.stdout!r}; stderr={result.stderr!r}"
assert result.returncode == 0, f"subprocess failed: {diag}"
assert f"ENV_MID: {expected_env!r}" in result.stderr, (
f"package import unexpectedly stripped env (regression in __init__.py): {diag}"
)
assert "SENTINEL_IN_PATH: False" in result.stderr, (
f"package import did not filter sys.path (regression in __init__.py): {diag}"
)
assert "ENV_AFTER: None" in result.stderr, f"MCP server did not strip PYTHONPATH: {diag}"
def _patch_mcp_server(monkeypatch, config, kg):
"""Patch the mcp_server module globals to use test fixtures."""
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_config", config)
# Accept varargs because production ``_get_kg`` now takes an optional
# canonical_path; ``_call_kg`` passes the captured key through.
monkeypatch.setattr(mcp_server, "_get_kg", lambda *a, **kw: kg)
def _get_collection(palace_path, create=False):
"""Helper to get collection from test palace.
Returns (client, collection) so callers can clean up the client
when they are done.
"""
import chromadb
client = chromadb.PersistentClient(path=palace_path)
if create:
return (
client,
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"}),
)
return client, client.get_collection("mempalace_drawers")
# ── Cold-start diagnostics (#1495) ──────────────────────────────────────
class TestColdStartDiagnostics:
"""``MEMPALACE_LOG_FILE`` + ``MEMPALACE_EAGER_WARMUP`` (#1495).
Each test runs ``main()`` in a fresh ``subprocess`` because
* ``_init_logging`` configures logging only at module import, so each
case needs a fresh interpreter to observe a pristine root logger and
configure host logging *before* importing the server,
* ``ChromaBackend._resolve_embedding_function`` is a class-level
attribute that test monkeypatching mutates globally,
* The whole point of the new env vars is process-startup behaviour
and must be exercised under a real ``main()`` boot path.
Pattern mirrors ``test_mcp_main_strips_leaked_pythonpath_from_env``.
``_run_main`` injects ``extra_code`` as a hard-coded ``-c`` source
fragment from this file only (no untrusted input flows in); the
subprocess argv form ``[sys.executable, "-c", code]`` avoids shell
interpretation entirely.
"""
@staticmethod
def _run_main(env_overrides: dict, extra_code: str = "", timeout: int = 30):
env = {
k: v
for k, v in os.environ.items()
if k not in env_overrides or env_overrides[k] is not None
}
for k, v in env_overrides.items():
if v is None:
env.pop(k, None)
else:
env[k] = v
code = extra_code + "from mempalace.mcp_server import main\nmain()\n"
return subprocess.run(
[sys.executable, "-c", code],
env=env,
input="", # empty stdin → readline() returns '' → loop breaks immediately
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
def test_log_file_unset_attaches_only_stream_handler(self, tmp_path):
marker = tmp_path / "handlers.txt"
env_overrides = {"MEMPALACE_LOG_FILE": None}
extra = (
"import logging, pathlib\n"
"from mempalace import mcp_server # noqa: F401 — triggers _init_logging()\n"
f"pathlib.Path({str(marker)!r}).write_text("
"','.join(type(h).__name__ for h in logging.getLogger().handlers)"
")\n"
"raise SystemExit(0)\n"
)
result = self._run_main(env_overrides, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert marker.read_text().split(",") == ["StreamHandler"], marker.read_text()
def test_log_file_empty_string_attaches_only_stream_handler(self, tmp_path):
marker = tmp_path / "handlers.txt"
env_overrides = {"MEMPALACE_LOG_FILE": " "} # whitespace counts as unset after .strip()
extra = (
"import logging, pathlib\n"
"from mempalace import mcp_server # noqa: F401\n"
f"pathlib.Path({str(marker)!r}).write_text("
"','.join(type(h).__name__ for h in logging.getLogger().handlers)"
")\n"
"raise SystemExit(0)\n"
)
result = self._run_main(env_overrides, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert marker.read_text().split(",") == ["StreamHandler"], marker.read_text()
def test_log_file_set_attaches_file_handler_and_persists_startup_line(self, tmp_path):
log_path = tmp_path / "mcp.log"
result = self._run_main({"MEMPALACE_LOG_FILE": str(log_path)})
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert log_path.exists(), f"log file missing; stderr={result.stderr!r}"
body = log_path.read_text(encoding="utf-8")
assert "MemPalace MCP Server starting" in body, body
def test_log_file_invalid_path_falls_back_to_stderr_with_warning(self, tmp_path):
# Unique directory name we can grep for cross-platform without
# depending on path-separator formatting in the %r warning value.
missing_dir = "missing_dir_for_1495"
bad_path = tmp_path / missing_dir / "mcp.log"
result = self._run_main({"MEMPALACE_LOG_FILE": str(bad_path)})
assert result.returncode == 0, f"stderr={result.stderr!r}"
# Invalid path must NOT crash the server, must surface a warning, must
# NOT create the file (the missing-directory ancestor is the failure).
# Warning must name MEMPALACE_LOG_FILE so the operator knows the source.
assert "could not be opened" in result.stderr, result.stderr
assert "MEMPALACE_LOG_FILE" in result.stderr, result.stderr
assert missing_dir in result.stderr, result.stderr
assert not bad_path.exists()
@staticmethod
def _make_fake_palace(tmp_path):
"""Create just enough on disk for ``_maybe_eager_warmup_embedder``'s
fresh-install pre-check to pass (``chroma.sqlite3`` exists).
Returns the palace dir as a string. The file is empty — production
code must not read its bytes during pre-check; only its existence
gates whether warmup proceeds to the chromadb client open.
"""
palace = tmp_path / "palace"
palace.mkdir()
make_minimal_chroma_sqlite(palace)
return str(palace)
@staticmethod
def _spy_get_collection_extra(marker_path, return_expr="None"):
"""Render an ``extra_code`` fragment that monkeypatches ``_get_collection``.
``marker_path`` records that the spy fired; ``return_expr`` is a Python
expression evaluated inside the subprocess for the call's return value
(e.g. ``"None"`` or ``"_FakeCol()"``).
"""
return (
"import pathlib\n"
"from mempalace import mcp_server\n"
"def _spy_get_collection(create=False):\n"
f" pathlib.Path({str(marker_path)!r}).write_text('called')\n"
f" return {return_expr}\n"
"mcp_server._get_collection = _spy_get_collection\n"
)
def test_eager_warmup_off_by_default_does_not_open_collection(self, tmp_path):
marker = tmp_path / "called.txt"
palace = self._make_fake_palace(tmp_path)
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": None, "MEMPALACE_PALACE_PATH": palace},
extra_code=self._spy_get_collection_extra(marker),
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert not marker.exists(), "warmup ran despite env var being unset"
@pytest.mark.parametrize("value", ["0", "false", "no", "off", "FALSE"])
def test_eager_warmup_explicit_falsy_skips_collection_open_without_warning(
self, tmp_path, value
):
marker = tmp_path / "called.txt"
palace = self._make_fake_palace(tmp_path)
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": value, "MEMPALACE_PALACE_PATH": palace},
extra_code=self._spy_get_collection_extra(marker),
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert not marker.exists(), f"warmup ran for explicit-falsy value {value!r}"
assert "not recognized" not in result.stderr, (
f"explicit-falsy {value!r} should not log a warning; stderr={result.stderr!r}"
)
@pytest.mark.parametrize("value", ["tru", "maybe", "ENABLED", "2"])
def test_eager_warmup_unrecognized_value_warns_and_skips_collection_open(self, tmp_path, value):
marker = tmp_path / "called.txt"
palace = self._make_fake_palace(tmp_path)
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": value, "MEMPALACE_PALACE_PATH": palace},
extra_code=self._spy_get_collection_extra(marker),
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert not marker.exists(), f"warmup ran despite unrecognized value {value!r}"
assert "not recognized" in result.stderr, result.stderr
@pytest.mark.parametrize("value", ["1", "true", "YES", "On"])
def test_eager_warmup_truthy_opens_collection_and_invokes_query(self, tmp_path, value):
"""C1 (#1495): warmup must call ``col.query(...)`` — not just open the collection.
ChromaDB's ``ONNXMiniLM_L6_V2.__init__`` only imports ``onnxruntime``;
``InferenceSession`` and model download happen inside ``__call__``,
which the chromadb query path drives. Pinning both call sites here
prevents a regression to a no-op resolver-only warmup (the same
failure mode silent-failure-hunter flagged in initial review).
Reporter's #1495 proposal: same path covers HNSW cold-load too.
"""
open_marker = tmp_path / "open_called.txt"
query_marker = tmp_path / "query_called.txt"
palace = self._make_fake_palace(tmp_path)
extra = (
"import pathlib\n"
"from mempalace import mcp_server\n"
"class _FakeCol:\n"
" def query(self, **kwargs):\n"
f" pathlib.Path({str(query_marker)!r}).write_text(repr(kwargs))\n"
" return {'ids': [[]], 'distances': [[]], 'documents': [[]]}\n"
"_fake_col = _FakeCol()\n"
"def _spy_get_collection(create=False):\n"
f" pathlib.Path({str(open_marker)!r}).write_text('open')\n"
" return _fake_col\n"
"mcp_server._get_collection = _spy_get_collection\n"
)
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": value, "MEMPALACE_PALACE_PATH": palace},
extra_code=extra,
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert open_marker.exists(), (
f"_get_collection not called for {value!r}; stderr={result.stderr!r}"
)
assert query_marker.exists(), (
f"col.query not invoked for {value!r} — warmup is a no-op "
f"(would let cold-load hit first MCP call); stderr={result.stderr!r}"
)
# query was called with the sentinel probe text and n_results=1.
kwargs_repr = query_marker.read_text()
assert "__mempalace_warmup_probe__" in kwargs_repr, kwargs_repr
assert "n_results" in kwargs_repr and "1" in kwargs_repr, kwargs_repr
# Success path logs embedder + HNSW readiness + palace + device for ops.
assert "embedder + HNSW ready" in result.stderr, result.stderr
assert f"palace={palace}" in result.stderr, result.stderr
def test_eager_warmup_fresh_install_skips_without_creating_palace(self, tmp_path):
"""Real integration test (no monkeypatch): an empty palace dir with no
``chroma.sqlite3`` must trigger the pre-check skip path BEFORE any
chromadb call materializes the palace scaffold on disk.
This pins three behaviours simultaneously:
1. ``returncode == 0`` — fresh install does not crash the server.
2. ``chroma.sqlite3`` is NOT created — warmup respects the
"no on-disk state before ``mempalace init``" contract from
CLAUDE.md ("Incremental only"). A regression that drops the
pre-check would let chromadb's ``PersistentClient(path=...)``
materialize the palace dir.
3. ``"nothing to warm"`` lands in stderr — the documented INFO
message actually fires (the previous test that asserted this
via a monkeypatched ``_get_collection`` was tautological because
the real ``_get_collection`` swallows ``NotFoundError`` into
``return None`` and silently materializes the palace).
4. No chromadb retry tracebacks ("attempt N/2 failed") leak into
stderr — those are the noise this PR exists to reduce.
"""
palace = tmp_path / "fresh_palace"
palace.mkdir()
# Confirm precondition: no chroma.sqlite3 exists before main().
db_path = palace / "chroma.sqlite3"
assert not db_path.exists()
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": "1", "MEMPALACE_PALACE_PATH": str(palace)},
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert "nothing to warm" in result.stderr, result.stderr
assert "collection open failed" not in result.stderr, result.stderr
assert "warmup query failed" not in result.stderr, result.stderr
assert "embedder + HNSW ready" not in result.stderr, result.stderr
assert "attempt 1/2 failed" not in result.stderr, result.stderr
assert "attempt 2/2 failed" not in result.stderr, result.stderr
# Pin the no-side-effect contract: the warmup MUST NOT create the
# palace scaffold on disk before the user runs ``mempalace init``.
assert not db_path.exists(), (
f"warmup materialized chroma.sqlite3 in a fresh palace dir "
f"(violates 'Incremental only' from CLAUDE.md); stderr={result.stderr!r}"
)
def test_eager_warmup_collection_returning_none_surfaces_warning(self, tmp_path):
"""_get_collection retries internally and returns None on persistent
failure (mcp_server.py:373). Warmup must not log a misleading
success line in that case."""
palace = self._make_fake_palace(tmp_path)
extra = self._spy_get_collection_extra(tmp_path / "called.txt", return_expr="None")
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": "1", "MEMPALACE_PALACE_PATH": palace},
extra_code=extra,
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert "_get_collection returned None" in result.stderr, result.stderr
assert "embedder + HNSW ready" not in result.stderr, result.stderr
def test_eager_warmup_collection_open_failure_logs_and_does_not_block_server(self, tmp_path):
palace = self._make_fake_palace(tmp_path)
extra = (
"from mempalace import mcp_server\n"
"def _boom(create=False):\n"
" raise RuntimeError('synthetic-collection-open-fail-1495')\n"
"mcp_server._get_collection = _boom\n"
)
result = self._run_main(
{"MEMPALACE_EAGER_WARMUP": "1", "MEMPALACE_PALACE_PATH": palace},
extra_code=extra,
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert "collection open failed" in result.stderr, result.stderr
assert "synthetic-collection-open-fail-1495" in result.stderr, result.stderr
# palace + error class included in the diagnostic
assert f"palace={palace}" in result.stderr, result.stderr
assert "error=RuntimeError" in result.stderr, result.stderr
def test_eager_warmup_query_failure_logs_and_persists_to_log_file(self, tmp_path):
"""Query may raise (broken HNSW, network failure during ONNX download,
runtime decoder error). Server stays up and the diagnostic lands in
both stderr AND ``MEMPALACE_LOG_FILE`` — the latter is the whole
point of #1495 for ops debugging the original -32000."""
palace = self._make_fake_palace(tmp_path)
log_path = tmp_path / "mcp.log"
extra = (
"from mempalace import mcp_server\n"
"class _BadCol:\n"
" def query(self, **kwargs):\n"
" raise RuntimeError('synthetic-query-fail-1495')\n"
"mcp_server._get_collection = lambda create=False: _BadCol()\n"
)
result = self._run_main(
{
"MEMPALACE_EAGER_WARMUP": "1",
"MEMPALACE_PALACE_PATH": palace,
"MEMPALACE_LOG_FILE": str(log_path),
},
extra_code=extra,
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert "warmup query failed" in result.stderr, result.stderr
assert "synthetic-query-fail-1495" in result.stderr, result.stderr
assert f"palace={palace}" in result.stderr, result.stderr
assert "error=RuntimeError" in result.stderr, result.stderr
assert log_path.exists(), f"log file not created; stderr={result.stderr!r}"
body = log_path.read_text(encoding="utf-8")
assert "warmup query failed" in body, body
assert "synthetic-query-fail-1495" in body, body
def test_log_file_path_with_embedded_newline_does_not_crash(self, tmp_path):
"""``MEMPALACE_LOG_FILE`` containing a newline (rare misconfig from
a YAML/env file copy-paste) must fall through the (OSError, ValueError)
catch rather than escape as an unhandled exception at import time."""
# Embedding \n inside a path component triggers ValueError on POSIX
# ("embedded null byte" raises on OS-level open) or OSError depending
# on platform — both should land in the fail-soft branch.
bad_path = str(tmp_path / "with\nnewline" / "mcp.log")
result = self._run_main({"MEMPALACE_LOG_FILE": bad_path})
assert result.returncode == 0, f"stderr={result.stderr!r}"
# Server proceeds with stderr-only and surfaces the env-var-named
# warning so ops can correlate the misconfig.
assert "could not be opened" in result.stderr, result.stderr
assert "MEMPALACE_LOG_FILE" in result.stderr, result.stderr
def test_log_file_invalid_path_failure_surfaces_before_first_log_record(self, tmp_path):
"""Behavioural pin: ``delay=True`` MUST NOT be used on the FileHandler.
With ``delay=True`` an invalid path raises inside ``emit()`` at runtime,
unhandled, defeating the fail-soft contract documented in ``_init_logging``.
This test pins the eager-open semantics by checking that the warning lands
BEFORE the ``MemPalace MCP Server starting...`` banner — proving that
``FileHandler.__init__`` raised and was caught at module import."""
bad_path = tmp_path / "regression_pin_dir" / "mcp.log"
result = self._run_main({"MEMPALACE_LOG_FILE": str(bad_path)})
assert result.returncode == 0, f"stderr={result.stderr!r}"
warning_pos = result.stderr.find("could not be opened")
banner_pos = result.stderr.find("MemPalace MCP Server starting")
assert warning_pos != -1, f"warning missing; stderr={result.stderr!r}"
assert banner_pos != -1, f"banner missing; stderr={result.stderr!r}"
assert warning_pos < banner_pos, (
f"warning at {warning_pos} must precede banner at {banner_pos} — "
f"if banner is first, FileHandler was opened lazily (delay=True regression). "
f"stderr={result.stderr!r}"
)
def test_host_root_logger_config_survives_import(self, tmp_path):
"""#1860: importing the server must NOT clobber a host app's root
logger. ``_init_logging`` previously called
``logging.basicConfig(force=True)`` at import, resetting root's
level, format, and handlers — silently overriding any app that
configured logging before importing ``mempalace.mcp_server``."""
marker = tmp_path / "rootstate.txt"
extra = (
"import logging, pathlib\n"
# Host app configures logging BEFORE importing mempalace.
"logging.basicConfig(level=logging.DEBUG, "
"format='HOST %(levelname)s %(message)s')\n"
"_sentinel = logging.NullHandler()\n"
"logging.getLogger().addHandler(_sentinel)\n"
"from mempalace import mcp_server # noqa: F401 — triggers _init_logging()\n"
"_root = logging.getLogger()\n"
"_fmt = next((h.formatter._fmt for h in _root.handlers "
"if h.formatter is not None), None)\n"
f"pathlib.Path({str(marker)!r}).write_text(\n"
" f'level={logging.getLevelName(_root.level)}|'\n"
" f'sentinel={_sentinel in _root.handlers}|'\n"
" f'nhandlers={len(_root.handlers)}|'\n"
" f'fmt={_fmt!r}'\n"
")\n"
"raise SystemExit(0)\n"
)
result = self._run_main({"MEMPALACE_LOG_FILE": None}, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
state = marker.read_text()
# Root logger must remain exactly as the host configured it.
assert "level=DEBUG" in state, state
assert "sentinel=True" in state, state
# MEMPALACE_LOG_FILE unset + host owns root → mempalace adds no handler.
assert "nhandlers=2" in state, state
assert "fmt='HOST %(levelname)s %(message)s'" in state, state
def test_log_file_with_host_root_captures_mempalace_only(self, tmp_path):
"""#1860 + #1495: when a host app owns the root logger and
MEMPALACE_LOG_FILE is set, the file still captures mempalace's own
records — including the dotted ``mempalace.*`` family (the cold-load
path) — but NOT the host's. Proves the additive, mempalace-filtered
file handler: a naive 'reset root' or 'single dedicated logger' fix
would either leak host logs into the file or drop the dotted family."""
log_path = tmp_path / "mcp.log"
extra = (
"import logging\n"
# Host owns root logging before the import.
"logging.basicConfig(level=logging.DEBUG, format='%(message)s')\n"
"from mempalace import mcp_server # noqa: F401 — triggers _init_logging()\n"
"logging.getLogger('host.app').warning('HOST-ONLY-LINE-xyz')\n"
"logging.getLogger('mempalace.embedding').info('MEMPALACE-DOTTED-LINE-xyz')\n"
"logging.getLogger('mempalace_mcp').info('MEMPALACE-FLAT-LINE-xyz')\n"
"logging.shutdown()\n"
"raise SystemExit(0)\n"
)
result = self._run_main({"MEMPALACE_LOG_FILE": str(log_path)}, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert log_path.exists(), f"log file missing; stderr={result.stderr!r}"
body = log_path.read_text(encoding="utf-8")
assert "MEMPALACE-DOTTED-LINE-xyz" in body, body
assert "MEMPALACE-FLAT-LINE-xyz" in body, body
assert "HOST-ONLY-LINE-xyz" not in body, body
# Format is "%(message)s" in the embedded path too: the line is the bare
# message with no "LEVEL:name:" prefix (the file handler sets its own
# formatter, independent of basicConfig which never runs here).
assert any(line == "MEMPALACE-FLAT-LINE-xyz" for line in body.splitlines()), body
def test_embedded_host_warning_root_gates_mempalace_info(self, tmp_path):
"""Documents the intentional embedded-mode level-gating tradeoff: when
a host owns root at WARNING, mempalace INFO heartbeats do NOT reach
MEMPALACE_LOG_FILE (the file handler rides on the host-gated root), but
WARNING/ERROR cold-load failure diagnostics still do. #1860 never
raises the host's level; #1495's motivating case is a standalone launch
(root empty -> INFO pinned) and is unaffected."""
log_path = tmp_path / "mcp.log"
extra = (
"import logging\n"
"logging.basicConfig(level=logging.WARNING, format='%(message)s')\n"
"from mempalace import mcp_server # noqa: F401 — triggers _init_logging()\n"
"logging.getLogger('mempalace_mcp').info('INFO-HEARTBEAT-xyz')\n"
"logging.getLogger('mempalace_mcp').warning('WARN-DIAG-xyz')\n"
"logging.shutdown()\n"
"raise SystemExit(0)\n"
)
result = self._run_main({"MEMPALACE_LOG_FILE": str(log_path)}, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
body = log_path.read_text(encoding="utf-8")
assert "WARN-DIAG-xyz" in body, body
assert "INFO-HEARTBEAT-xyz" not in body, body
def test_standalone_log_file_excludes_third_party_records(self, tmp_path):
"""The MEMPALACE_LOG_FILE stream is mempalace-only in standalone mode
too: third-party library records reaching the root logger are kept out
of the file by ``_MempalaceLogFilter`` (the file stays a clean
mempalace diagnostic stream)."""
log_path = tmp_path / "mcp.log"
extra = (
"import logging\n"
"from mempalace import mcp_server # noqa: F401 — standalone: root starts empty\n"
"logging.getLogger('chromadb.fake').warning('THIRDPARTY-LINE-xyz')\n"
"logging.getLogger('mempalace.embedding').info('MEMPALACE-STD-LINE-xyz')\n"
"logging.shutdown()\n"
"raise SystemExit(0)\n"
)
result = self._run_main({"MEMPALACE_LOG_FILE": str(log_path)}, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
body = log_path.read_text(encoding="utf-8")
assert "MEMPALACE-STD-LINE-xyz" in body, body
assert "THIRDPARTY-LINE-xyz" not in body, body
def test_reload_does_not_duplicate_file_handler(self, tmp_path):
"""#1885 review: the idempotency guard must survive ``importlib.reload``,
not only a direct second call. A reload re-executes the module body; the
guard flag is restored from ``globals()`` so ``_init_logging`` early-exits
and does not stack a second ``FileHandler`` on root."""
log_path = tmp_path / "mcp.log"
marker = tmp_path / "counts.txt"
extra = (
"import logging, importlib, pathlib\n"
"from mempalace import mcp_server\n"
"def _nfile():\n"
" return sum(\n"
" isinstance(h, logging.FileHandler)\n"
" for h in logging.getLogger().handlers\n"
" )\n"
"_before = _nfile()\n"
"importlib.reload(mcp_server)\n"
"_after = _nfile()\n"
f"pathlib.Path({str(marker)!r}).write_text(f'{{_before}},{{_after}}')\n"
"raise SystemExit(0)\n"
)
result = self._run_main({"MEMPALACE_LOG_FILE": str(log_path)}, extra_code=extra)
assert result.returncode == 0, f"stderr={result.stderr!r}"
before, after = marker.read_text().split(",")
assert before == "1", f"expected one file handler after import, got {before}"
assert after == "1", f"reload duplicated the file handler: {before}->{after}"
# ── Protocol Layer ──────────────────────────────────────────────────────
class TestHandleRequest:
def test_initialize(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "initialize", "id": 1, "params": {}})
assert resp["result"]["serverInfo"]["name"] == "mempalace"
assert resp["id"] == 1
def test_initialize_negotiates_client_version(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "initialize",
"id": 1,
"params": {"protocolVersion": "2025-11-25"},
}
)
assert resp["result"]["protocolVersion"] == "2025-11-25"
def test_initialize_negotiates_older_supported_version(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "initialize",
"id": 1,
"params": {"protocolVersion": "2025-03-26"},
}
)
assert resp["result"]["protocolVersion"] == "2025-03-26"
def test_initialize_unknown_version_falls_back_to_latest(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "initialize",
"id": 1,
"params": {"protocolVersion": "9999-12-31"},
}
)
from mempalace.mcp_server import SUPPORTED_PROTOCOL_VERSIONS
assert resp["result"]["protocolVersion"] == SUPPORTED_PROTOCOL_VERSIONS[0]
def test_initialize_missing_version_uses_oldest(self):
from mempalace.mcp_server import handle_request, SUPPORTED_PROTOCOL_VERSIONS
resp = handle_request({"method": "initialize", "id": 1, "params": {}})
assert resp["result"]["protocolVersion"] == SUPPORTED_PROTOCOL_VERSIONS[-1]
def test_notifications_initialized_returns_none(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "notifications/initialized", "id": None, "params": {}})
assert resp is None
def test_ping_returns_empty_result(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "ping", "id": 11, "params": {}})
assert resp["id"] == 11
assert resp["result"] == {}
def test_tools_list(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "tools/list", "id": 2, "params": {}})
tools = resp["result"]["tools"]
names = {t["name"] for t in tools}
assert "mempalace_status" in names
assert "mempalace_search" in names
assert "mempalace_add_drawer" in names
assert "mempalace_kg_add" in names
def test_no_tool_schema_uses_top_level_combinator(self):
"""Anthropic's Messages API rejects a tool whose input schema has a
top-level anyOf/oneOf/allOf and drops the entire tools array with a
400, killing the session (#1711). Cross-tool constraints must be
enforced at dispatch instead.
"""
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "tools/list", "id": 2, "params": {}})
for tool in resp["result"]["tools"]:
schema = tool["inputSchema"]
for keyword in ("anyOf", "oneOf", "allOf"):
assert keyword not in schema, f"{tool['name']} schema has top-level {keyword}"
def test_null_arguments_does_not_hang(self, monkeypatch, config, palace_path, seeded_kg):
"""Sending arguments: null should return a result, not hang (#394)."""
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import handle_request
_client, _col = _get_collection(palace_path, create=True)
del _client
resp = handle_request(
{
"method": "tools/call",
"id": 10,
"params": {"name": "mempalace_status", "arguments": None},
}
)
assert "error" not in resp
assert resp["result"] is not None
def test_unknown_tool(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "tools/call",
"id": 3,
"params": {"name": "nonexistent_tool", "arguments": {}},
}
)
assert resp["error"]["code"] == -32601
def test_tools_call_missing_params(self):
from mempalace.mcp_server import handle_request
for bad_params in [None, {}, {"arguments": {}}]:
resp = handle_request(
{
"method": "tools/call",
"id": 15,
"params": bad_params,
}
)
assert resp["error"]["code"] == -32602
assert "Invalid params" in resp["error"]["message"]
def test_unknown_method(self):
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "unknown/method", "id": 4, "params": {}})
assert resp["error"]["code"] == -32601
def test_any_notification_returns_none(self):
"""All notifications/* methods should return None (no response)."""
from mempalace.mcp_server import handle_request
for method in [
"notifications/initialized",
"notifications/cancelled",
"notifications/progress",
"notifications/roots/list_changed",
]:
resp = handle_request({"method": method, "params": {}})
assert resp is None, f"{method} should return None"
def test_unknown_method_no_id_returns_none(self):
"""Messages without id (notifications) must never get a response."""
from mempalace.mcp_server import handle_request
resp = handle_request({"method": "unknown/thing", "params": {}})
assert resp is None
def test_malformed_method_none(self):
"""method=None or missing should not crash."""
from mempalace.mcp_server import handle_request
# Explicit None
resp = handle_request({"method": None, "params": {}})
assert resp is None # no id → no response
# Missing method entirely
resp = handle_request({"params": {}})
assert resp is None
# method=None with id → should return error, not crash
resp = handle_request({"method": None, "id": 99, "params": {}})
assert resp["error"]["code"] == -32601
@pytest.mark.parametrize("payload", [None, [], "plain", 42, True])
def test_handle_request_invalid_payload_returns_jsonrpc_error(self, payload):
from mempalace.mcp_server import handle_request
resp = handle_request(payload)
assert resp == {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Invalid Request"},
}
def test_tools_call_dispatches(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import handle_request
# Create a collection so status works
_client, _col = _get_collection(palace_path, create=True)
del _client
resp = handle_request(
{
"method": "tools/call",
"id": 5,
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert "result" in resp
content = json.loads(resp["result"]["content"][0]["text"])
assert "total_drawers" in content
# ── Read Tools ──────────────────────────────────────────────────────────
class TestReadTools:
def test_status_cold_start_no_collection(self, monkeypatch, config, palace_path, kg):
"""Status on a valid palace with no ChromaDB collection yet (#830).
After `mempalace init`, chroma.sqlite3 exists but the mempalace_drawers
collection has not been created (no mine or add_drawer yet). Status
should return total_drawers: 0, not 'No palace found'.
"""
import chromadb
_patch_mcp_server(monkeypatch, config, kg)
# Create the DB file (init does this) but NOT the collection
client = chromadb.PersistentClient(path=palace_path)
del client
from mempalace.mcp_server import tool_status
result = tool_status()
assert "error" not in result, f"cold-start should not error: {result}"
assert result["total_drawers"] == 0
def test_status_empty_palace(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_status
result = tool_status()
assert result["total_drawers"] == 0
assert result["wings"] == {}
def test_status_with_data(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
result = tool_status()
assert result["total_drawers"] == 4
assert "project" in result["wings"]
assert "notes" in result["wings"]
def test_status_sqlite_exact_backend_has_no_hnsw_fields(
self, monkeypatch, config, palace_path, kg
):
import mempalace.backends.embedding_wrapper as embedding_wrapper
from mempalace.palace import get_collection
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
monkeypatch.setattr(
embedding_wrapper,
"_embed_texts",
lambda texts: [[float(len(text)), 1.0] for text in texts],
)
col = get_collection(palace_path, create=True)
col.add(
ids=["drawer_sqlite"],
documents=["verbatim sqlite drawer"],
metadatas=[{"wing": "w", "room": "r"}],
)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_collection_cache", None)
result = mcp_server.tool_status()
assert result["backend"] == "sqlite_exact"
assert result["total_drawers"] == 1
assert "hnsw_capacity" not in result
assert result.get("vector_disabled") is not True
def test_status_qdrant_backend_has_no_hnsw_fields(self, monkeypatch, config, palace_path, kg):
from mempalace.backends import GetResult
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "qdrant")
monkeypatch.setenv("MEMPALACE_BACKEND", "qdrant")
with open(os.path.join(palace_path, "qdrant_backend.json"), "w", encoding="utf-8") as f:
json.dump({"backend": "qdrant"}, f)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
class _FakeQdrantCollection:
def count(self):
return 2
def get(self, **_kwargs):
return GetResult(
ids=["q1", "q2"],
documents=[],
metadatas=[
{"wing": "project", "room": "backend"},
{"wing": "project", "room": "api"},
],
)
monkeypatch.setattr(mcp_server, "_collection_cache", None)
monkeypatch.setattr(mcp_server, "_metadata_cache", None)
monkeypatch.setattr(
mcp_server, "_get_collection", lambda create=False: _FakeQdrantCollection()
)
result = mcp_server.tool_status()
assert result["backend"] == "qdrant"
assert result["total_drawers"] == 2
assert result["wings"] == {"project": 2}
assert "hnsw_capacity" not in result
assert result.get("vector_disabled") is not True
def test_status_handles_none_metadata_without_partial(
self, monkeypatch, config, palace_path, kg
):
"""tool_status must not crash or go partial when the metadata cache
returns a ``None`` entry — palaces can contain drawers with no
metadata (older mining paths, third-party writes). Before the guard,
``m.get("wing")`` raised AttributeError mid-tally and the result
carried ``"error"`` + ``"partial": True`` even though the data was
perfectly fetchable."""
from unittest.mock import patch as _patch
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
# Inject a metadata cache where one entry is None
with _patch("mempalace.mcp_server._get_collection") as mock_get_col:
fake_col = type("C", (), {"count": lambda self: 2})()
mock_get_col.return_value = fake_col
with _patch(
"mempalace.mcp_server._get_cached_metadata",
return_value=[{"wing": "proj", "room": "r"}, None],
):
result = tool_status()
# The None-metadata drawer falls under 'unknown/unknown' — no crash,
# no partial flag.
assert "error" not in result
assert result.get("partial") is not True
assert result["total_drawers"] == 2
assert result["wings"].get("proj") == 1
assert result["wings"].get("unknown") == 1
def test_list_wings(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_wings
result = tool_list_wings()
assert result["wings"]["project"] == 3
assert result["wings"]["notes"] == 1
def test_list_rooms_all(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_rooms
result = tool_list_rooms()
assert "backend" in result["rooms"]
assert "frontend" in result["rooms"]
assert "planning" in result["rooms"]
def test_list_rooms_filtered(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_rooms
result = tool_list_rooms(wing="project")
assert "backend" in result["rooms"]
assert "planning" not in result["rooms"]
def test_get_taxonomy(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_taxonomy
result = tool_get_taxonomy()
assert result["taxonomy"]["project"]["backend"] == 2
assert result["taxonomy"]["project"]["frontend"] == 1
assert result["taxonomy"]["notes"]["planning"] == 1
def test_overview_tools_use_sqlite_fast_path(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
"""Overview tools must answer from the sqlite cross-tab without paging
all metadata through the chroma client (#1748 / #1379). A tripwire on
the pagination helper fails loudly if the fast path regresses to the
slow client path that times out on large palaces."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
def _boom(*_a, **_k):
raise AssertionError("pagination path used instead of sqlite fast path")
monkeypatch.setattr(mcp_server, "_metadata_cache", None)
monkeypatch.setattr(mcp_server, "_fetch_all_metadata", _boom)
status = mcp_server.tool_status()
assert status["total_drawers"] == 4
assert status["wings"] == {"project": 3, "notes": 1}
assert mcp_server.tool_list_wings()["wings"] == {"project": 3, "notes": 1}
rooms = mcp_server.tool_list_rooms(wing="project")["rooms"]
assert rooms == {"backend": 2, "frontend": 1}
tax = mcp_server.tool_get_taxonomy()["taxonomy"]
assert tax["project"] == {"backend": 2, "frontend": 1}
assert tax["notes"] == {"planning": 1}
def test_overview_tools_normalize_missing_wing_room_to_unknown(
self, monkeypatch, config, palace_path, collection, kg
):
"""Fast path must keep the client path's contract: drawers missing
wing/room metadata read as 'unknown', not the sqlite COALESCE
placeholder '?' (#1748 review)."""
collection.add(
ids=["no_meta_drawer"],
documents=["a drawer with no wing or room metadata"],
metadatas=[{"source_file": "loose.txt"}],
)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_metadata_cache", None)
tax = mcp_server.tool_get_taxonomy()["taxonomy"]
assert tax == {"unknown": {"unknown": 1}}
status = mcp_server.tool_status()
assert status["wings"] == {"unknown": 1}
assert status["rooms"] == {"unknown": 1}
def test_graph_stats_uses_sqlite_fast_path(
self, monkeypatch, config, palace_path, collection, kg
):
"""graph_stats must aggregate from sqlite without paging metadata
through build_graph()/HNSW (#1379). Mirrors the build_graph parity
case in test_palace_graph. Tripwires fail loudly if the fast path
regresses: graph_stats() (the slow client build) and _get_collection()
(any client/HNSW open) must never be reached."""
collection.add(
ids=["d_db_code", "d_db_proj", "d_auth", "d_general", "d_orphan"],
documents=[
"chromadb setup in the code wing",
"chromadb usage in the project wing",
"auth and security notes",
"a general catch-all drawer",
"a drawer with no wing",
],
metadatas=[
{"room": "chromadb", "wing": "wing_code", "hall": "db"},
{"room": "chromadb", "wing": "wing_project", "hall": "db"},
{"room": "auth", "wing": "wing_code", "hall": "security"},
{"room": "general", "wing": "wing_code", "hall": "misc"},
{"room": "orphan", "source_file": "loose.txt"},
],
)
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
def _boom(*_a, **_k):
raise AssertionError("build_graph client path used instead of sqlite fast path")
def _no_client_open(*_a, **_k):
raise AssertionError("chroma collection opened — fast path must avoid HNSW")
monkeypatch.setattr(mcp_server, "graph_stats", _boom)
monkeypatch.setattr(mcp_server, "_get_collection", _no_client_open)
stats = mcp_server.tool_graph_stats()
# "general" room and the wing-less drawer are excluded, matching
# build_graph's per-drawer filter.
assert stats["total_rooms"] == 2
assert stats["tunnel_rooms"] == 1
assert stats["total_edges"] == 1
assert stats["rooms_per_wing"] == {"wing_code": 2, "wing_project": 1}
assert stats["top_tunnels"] == [
{"room": "chromadb", "wings": ["wing_code", "wing_project"], "count": 2}
]
def test_no_palace_returns_error(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
result = tool_status()
assert "error" in result
# ── Regression: None-metadata safety (issue #1426) ──────────────────────
class TestMetadataFacets:
def test_tool_status_uses_metadata_facets(self, monkeypatch):
from unittest.mock import MagicMock
import mempalace.mcp_server as mcp
monkeypatch.setattr(mcp, "_sqlite_taxonomy", lambda: None)
monkeypatch.setattr(mcp, "_supports_metadata_facets", lambda _: True)
col = MagicMock()
col.count.return_value = 5
col.facet_counts.side_effect = [
{"wing_a": 2, "wing_b": 3},
{"room_x": 4, "room_y": 1},
]
monkeypatch.setattr(mcp, "_get_collection", lambda create=False: col)
result = mcp.tool_status()
assert result["wings"] == {
"wing_a": 2,
"wing_b": 3,
}
assert result["rooms"] == {
"room_x": 4,
"room_y": 1,
}
assert col.facet_counts.call_count == 2
def test_tool_list_wings_uses_metadata_facets(self, monkeypatch):
from unittest.mock import MagicMock
import mempalace.mcp_server as mcp
monkeypatch.setattr(mcp, "_sqlite_taxonomy", lambda: None)
monkeypatch.setattr(mcp, "_supports_metadata_facets", lambda _: True)
col = MagicMock()
col.facet_counts.return_value = {
"wing_a": 5,
"wing_b": 2,
}
monkeypatch.setattr(mcp, "_get_collection", lambda: col)
result = mcp.tool_list_wings()
assert result == {
"wings": {
"wing_a": 5,
"wing_b": 2,
}
}
col.facet_counts.assert_called_once_with("wing")
def test_tool_list_rooms_uses_metadata_facets(self, monkeypatch):
from unittest.mock import MagicMock
import mempalace.mcp_server as mcp
monkeypatch.setattr(mcp, "_sqlite_taxonomy", lambda: None)
monkeypatch.setattr(mcp, "_supports_metadata_facets", lambda _: True)
col = MagicMock()
col.facet_counts.return_value = {
"room1": 7,
"room2": 3,
}
monkeypatch.setattr(mcp, "_get_collection", lambda: col)
result = mcp.tool_list_rooms("engineering")
assert result["rooms"] == {
"room1": 7,
"room2": 3,
}
from unittest.mock import call
assert col.facet_counts.call_args_list == [
call("room", where={"wing": "engineering"}),
call("wing", where={"wing": "engineering"}),
]
def test_tool_get_taxonomy_uses_metadata_facets(self, monkeypatch):
from unittest.mock import MagicMock, call
import mempalace.mcp_server as mcp
monkeypatch.setattr(mcp, "_sqlite_taxonomy", lambda: None)
monkeypatch.setattr(mcp, "_supports_metadata_facets", lambda _: True)
col = MagicMock()
def facet_counts_mock(field, where=None):
if field == "wing":
return {"wing_a": 2, "wing_b": 1}
if field == "room" and where == {"wing": "wing_a"}:
return {"room1": 2}
if field == "room" and where == {"wing": "wing_b"}:
return {"room2": 1}
return {}
col.facet_counts.side_effect = facet_counts_mock
monkeypatch.setattr(mcp, "_get_collection", lambda: col)
result = mcp.tool_get_taxonomy()
assert col.facet_counts.call_args_list[0] == call("wing")
# Per-wing room facets run concurrently (ThreadPoolExecutor), so order is
# non-deterministic. Compare order-independently without a set() — a
# ``call`` carrying a dict kwarg is unhashable, so membership (==) is used.
room_calls = col.facet_counts.call_args_list[1:]
assert len(room_calls) == 2
assert call("room", where={"wing": "wing_a"}) in room_calls
assert call("room", where={"wing": "wing_b"}) in room_calls
assert result["taxonomy"] == {
"wing_a": {
"room1": 2,
},
"wing_b": {
"room2": 1,
},
}
class TestNoneMetadataSafety:
"""Regression coverage for issue #1426.
ChromaDB's ``col.get()`` / ``col.query()`` can return ``None`` for the
metadata cell of a partially-flushed row or any row written without
metadata in older formats. Before the ``_safe_meta`` boundary helper,
indexing the result yielded ``None``, the next ``.get(...)`` raised
``AttributeError: 'NoneType' object has no attribute 'get'``, and the
handler crashed before the ``DELETE FROM embeddings_queue`` cleanup
step — so the queue grew without bound while writes kept appearing
successful.
Each test simulates Chroma returning ``None`` in the metadatas list
via a stub collection — Chroma's own write path rejects ``None`` at
insert time, so we can't reproduce the upstream state by writing
bad data through the real backend. Mocking ``_get_collection`` lets
us assert the handler tolerates the failure mode that actually shows
up in the wild.
"""
def test_safe_meta_helper_coerces_none_to_empty_dict(self):
from mempalace.mcp_server import _safe_meta
assert _safe_meta(None) == {}
assert _safe_meta({}) == {}
assert _safe_meta({"wing": "x"}) == {"wing": "x"}
# Defensive against other non-dict types Chroma might return on
# malformed rows — coerce, don't crash.
assert _safe_meta("not a dict") == {}
assert _safe_meta(["wing", "x"]) == {}
def test_get_drawer_tolerates_none_metadata(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from unittest.mock import MagicMock
from mempalace import mcp_server
stub_col = MagicMock()
stub_col.get.return_value = {
"ids": ["drawer_none_meta"],
"documents": ["verbatim body"],
"metadatas": [None],
}
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: stub_col)
result = mcp_server.tool_get_drawer("drawer_none_meta")
assert "error" not in result
assert result["drawer_id"] == "drawer_none_meta"
# Missing metadata reduces to empty defaults — no crash, no leak.
assert result["wing"] == ""
assert result["room"] == ""
assert result["content"] == "verbatim body"
def test_list_drawers_tolerates_none_metadata(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from unittest.mock import MagicMock
from mempalace import mcp_server
stub_col = MagicMock()
stub_col.get.return_value = {
"ids": ["drawer_a", "drawer_b"],
"documents": ["body a", "body b"],
"metadatas": [None, {"wing": "ok", "room": "fine"}],
}
stub_col.count.return_value = 2
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: stub_col)
result = mcp_server.tool_list_drawers()
assert result["count"] == 2
assert result["drawers"][0]["wing"] == ""
assert result["drawers"][0]["room"] == ""
assert result["drawers"][1]["wing"] == "ok"
assert result["drawers"][1]["room"] == "fine"
def test_update_drawer_tolerates_none_metadata(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from unittest.mock import MagicMock
from mempalace import mcp_server
stub_col = MagicMock()
stub_col.get.return_value = {
"ids": ["drawer_none_meta"],
"documents": ["old body"],
"metadatas": [None],
}
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: stub_col)
result = mcp_server.tool_update_drawer("drawer_none_meta", wing="recovered")
# Should succeed: old_meta is coerced to {}, new wing slots in cleanly.
assert result.get("success") is True
# Confirm the update call carried the new wing without inheriting None.
update_call = stub_col.update.call_args
assert update_call is not None
new_meta = update_call.kwargs["metadatas"][0]
assert new_meta["wing"] == "recovered"
def test_delete_drawer_audit_log_tolerates_none_metadata(
self, monkeypatch, config, palace_path, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from unittest.mock import MagicMock
from mempalace import mcp_server
stub_col = MagicMock()
stub_col.get.return_value = {
"ids": ["drawer_none_meta"],
"documents": ["doomed body"],
"metadatas": [None],
}
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: stub_col)
# Should reach the delete call without AttributeError on the audit-log path.
result = mcp_server.tool_delete_drawer("drawer_none_meta")
assert result["success"] is True
stub_col.delete.assert_called_once_with(ids=["drawer_none_meta"])
# ── Search Tool ─────────────────────────────────────────────────────────
class TestSearchTool:
def test_search_basic(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT authentication tokens")
assert "results" in result
assert len(result["results"]) > 0
# Top result should be the auth drawer
top = result["results"][0]
assert "JWT" in top["text"] or "authentication" in top["text"].lower()
def test_search_with_wing_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="planning", wing="notes")
assert all(r["wing"] == "notes" for r in result["results"])
def test_search_with_room_filter(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="database", room="backend")
assert all(r["room"] == "backend" for r in result["results"])
def test_search_with_source_file_filter(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="authentication module", source_file="auth.py")
assert result["results"]
assert all(r["source_file"] == "auth.py" for r in result["results"])
assert result["filters"]["source_file"] == "auth.py"
def test_search_source_file_allows_path_separators(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
# Unlike wing/room, a source_file is a path — '/' must NOT be rejected
# as a path-traversal attempt the way sanitize_name() would.
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="authentication", source_file="/abs/path/to/auth.py")
assert "error" not in result
def test_search_blank_source_file_ignored(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT authentication", source_file=" ")
assert "results" in result
assert result["filters"]["source_file"] is None
def test_search_rejects_null_byte_source_file(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
# A null byte in a metadata where-value can crash chromadb add/upsert
# (#1235 lineage); reject it cleanly the way sanitize_name does.
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT", source_file="bad\x00null")
assert "error" in result
def test_search_rejects_overlong_source_file(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT", source_file="x" * 5000)
assert "error" in result
def test_search_rejects_non_string_source_file(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
# A non-string source_file (e.g. a JSON number, which the schema's
# string type does not coerce) must yield a clean validation error,
# not an unhandled AttributeError from .strip().
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT", source_file=42)
assert "error" in result
def test_search_rejects_lone_surrogate_source_file(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
# A lone UTF-16 surrogate can crash chromadb (#1235); reject it for
# parity with sanitize_name rather than letting it reach the backend.
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
result = tool_search(query="JWT", source_file="bad\udc80surrogate")
assert "error" in result
def test_search_accepts_source_file_at_length_boundary(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
# Exactly _MAX_SOURCE_FILE_LENGTH is allowed (the cap is a strict '>').
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import _MAX_SOURCE_FILE_LENGTH, tool_search
result = tool_search(query="JWT", source_file="x" * _MAX_SOURCE_FILE_LENGTH)
assert "error" not in result
def test_search_min_similarity_backwards_compat(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
"""Old min_similarity param still works via backwards-compat shim."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_search
# Old name should work
result = tool_search(query="JWT", min_similarity=1.5)
assert "results" in result
# Old name takes precedence when both provided
result_strict = tool_search(query="JWT", max_distance=999.0, min_similarity=0.01)
result_loose = tool_search(query="JWT", max_distance=0.01, min_similarity=999.0)
assert len(result_strict["results"]) <= len(result_loose["results"])
def test_list_rooms_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda: pytest.fail())
result = mcp_server.tool_list_rooms(wing="../etc/passwd")
assert "error" in result
def test_search_rejects_invalid_room(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "search_memories", lambda: pytest.fail())
result = mcp_server.tool_search(query="JWT", room="../backend")
assert "error" in result
def test_search_retries_once_on_hnsw_flush_transient(self, monkeypatch, config, kg):
"""Issue #1315: post-bulk-mine 'Error finding id' is retried once."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
calls = {"n": 0}
reset_calls = {"n": 0}
def fake_search(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return {
"error": "Search error: Error executing plan: Internal error: Error finding id"
}
return {"results": [{"text": "ok", "wing": "w", "room": "r"}]}
def fake_reset():
reset_calls["n"] += 1
monkeypatch.setattr(mcp_server, "search_memories", fake_search)
monkeypatch.setattr(mcp_server, "_force_chroma_cache_reset", fake_reset)
monkeypatch.setattr(mcp_server.time, "sleep", lambda _: None)
result = mcp_server.tool_search(query="anything")
assert calls["n"] == 2
assert reset_calls["n"] == 1
assert "results" in result
assert result.get("index_recovered") is True
def test_search_retry_preserves_collection_name(self, monkeypatch, config, kg):
"""Retry path must query the same configured collection both times."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server,
"_config",
SimpleNamespace(
palace_path=config.palace_path,
collection_name="custom_drawers",
),
)
seen_collection_names = []
def fake_search(*args, **kwargs):
seen_collection_names.append(kwargs.get("collection_name"))
if len(seen_collection_names) == 1:
return {
"error": "Search error: Error executing plan: Internal error: Error finding id"
}
return {"results": [{"text": "ok", "wing": "w", "room": "r"}]}
monkeypatch.setattr(mcp_server, "search_memories", fake_search)
monkeypatch.setattr(mcp_server, "_force_chroma_cache_reset", lambda: None)
monkeypatch.setattr(mcp_server.time, "sleep", lambda _: None)
result = mcp_server.tool_search(query="anything", wing="wing_api")
assert "results" in result
assert seen_collection_names == ["custom_drawers", "custom_drawers"]
def test_search_does_not_retry_on_non_transient_error(self, monkeypatch, config, kg):
"""Validation / unrelated errors must not trigger the retry path."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
calls = {"n": 0}
def fake_search(*args, **kwargs):
calls["n"] += 1
return {"error": "Search error: invalid query syntax"}
monkeypatch.setattr(mcp_server, "search_memories", fake_search)
result = mcp_server.tool_search(query="anything")
assert calls["n"] == 1
assert "error" in result
assert "index_recovered" not in result
def test_search_returns_second_error_if_retry_also_fails(self, monkeypatch, config, kg):
"""If the transient persists past the retry, surface the second error."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
calls = {"n": 0}
def fake_search(*args, **kwargs):
calls["n"] += 1
return {"error": "Search error: Error executing plan: Internal error: Error finding id"}
monkeypatch.setattr(mcp_server, "search_memories", fake_search)
monkeypatch.setattr(mcp_server, "_force_chroma_cache_reset", lambda: None)
monkeypatch.setattr(mcp_server.time, "sleep", lambda _: None)
result = mcp_server.tool_search(query="anything")
assert calls["n"] == 2
assert "error" in result
assert "index_recovered" not in result
def test_search_retries_once_on_stale_index_error(self, monkeypatch, config, kg):
"""Stale-index errors should trigger one cache-reset retry."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
calls = {"n": 0}
reset_calls = {"n": 0}
def fake_search(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return {"error": "Search error: stale-index detected; retry recommended"}
return {"results": [{"text": "ok", "wing": "w", "room": "r"}]}
def fake_reset():
reset_calls["n"] += 1
monkeypatch.setattr(mcp_server, "search_memories", fake_search)
monkeypatch.setattr(mcp_server, "_force_chroma_cache_reset", fake_reset)
monkeypatch.setattr(mcp_server.time, "sleep", lambda _: None)
result = mcp_server.tool_search(query="anything")
assert calls["n"] == 2
assert reset_calls["n"] == 1
assert "results" in result
assert result.get("index_recovered") is True
def test_list_drawers_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda: pytest.fail())
result = mcp_server.tool_list_drawers(wing="../notes")
assert "error" in result
def test_find_tunnels_rejects_invalid_wing(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_get_collection", lambda: pytest.fail())
result = mcp_server.tool_find_tunnels(wing_a="../project")
assert "error" in result
def test_wal_redacts_sensitive_fields(self, monkeypatch, config, kg, tmp_path):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import wal
wal_file = tmp_path / "write_log.jsonl"
monkeypatch.setattr(wal, "_WAL_FILE", wal_file)
wal._wal_log(
"test",
{"content": "secret note", "query": "private search", "safe": "ok"},
)
entry = json.loads(wal_file.read_text().strip())
assert entry["params"]["content"].startswith("[REDACTED")
assert entry["params"]["query"].startswith("[REDACTED")
assert entry["params"]["safe"] == "ok"
# ── Write Tools ─────────────────────────────────────────────────────────
class TestWriteTools:
def test_add_drawer(self, monkeypatch, config, palace_path, kg):
_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="test_wing",
room="test_room",
content="This is a test memory about Python decorators and metaclasses.",
)
assert result["success"] is True
assert result["wing"] == "test_wing"
assert result["room"] == "test_room"
assert result["drawer_id"].startswith("drawer_test_wing_test_room_")
def test_add_drawer_duplicate_detection(self, monkeypatch, config, palace_path, kg):
_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 = "This is a unique test memory about Rust ownership and borrowing."
result1 = tool_add_drawer(wing="w", room="r", content=content)
assert result1["success"] is True
result2 = tool_add_drawer(wing="w", room="r", content=content)
assert result2["success"] is True
assert result2["reason"] == "already_exists"
def test_add_drawer_returns_failure_when_idempotency_precheck_raises(
self, monkeypatch, config, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
mock_col = MagicMock()
mock_col.get.side_effect = RuntimeError("precheck boom")
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: mock_col)
result = mcp_server.tool_add_drawer("w", "r", "content")
assert result["success"] is False
assert "Idempotency check failed before write" in result["error"]
assert "precheck boom" in result["error"]
def test_add_drawer_does_not_upsert_when_idempotency_precheck_raises(
self, monkeypatch, config, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
mock_col = MagicMock()
mock_col.get.side_effect = RuntimeError("precheck boom")
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: mock_col)
result = mcp_server.tool_add_drawer("w", "r", "content")
assert result["success"] is False
mock_col.upsert.assert_not_called()
def test_add_drawer_treats_dict_like_precheck_hit_as_already_exists(
self, monkeypatch, config, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
mock_col = MagicMock()
mock_col.get.return_value = {"ids": ["existing-drawer"]}
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: mock_col)
result = mcp_server.tool_add_drawer("w", "r", "content")
assert result["success"] is True
assert result["reason"] == "already_exists"
mock_col.upsert.assert_not_called()
def test_get_result_ids_normalizes_none_to_empty_list(self):
from mempalace import mcp_server
class DictLikeResult:
def get(self, key, default=None):
return None
assert mcp_server._get_result_ids({"ids": None}) == []
assert mcp_server._get_result_ids(DictLikeResult()) == []
def test_add_drawer_fails_when_readback_misses(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
class _FakeGetResult:
ids = []
class _FakeCol:
def get(self, **kwargs):
return _FakeGetResult()
def upsert(self, **kwargs):
return None
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: _FakeCol())
result = mcp_server.tool_add_drawer("w", "r", "content")
assert result["success"] is False
assert "not readable" in result["error"]
def test_add_drawer_shared_header_no_collision(self, monkeypatch, config, palace_path, kg):
"""Documents sharing a >100-char header must get distinct IDs (full-content hash)."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
header = "# ACME Corp Knowledge Base\n**Project:** Alpha | **Team:** Backend | **Status:** Active\n\n"
doc1 = (
header
+ "Decision: Use PostgreSQL for primary storage. Rationale: ACID compliance required."
)
doc2 = header + "Decision: Use Redis for session caching. Rationale: sub-ms latency needed."
result1 = tool_add_drawer(wing="work", room="decisions", content=doc1)
result2 = tool_add_drawer(wing="work", room="decisions", content=doc2)
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"
)
def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_delete_drawer
result = tool_delete_drawer("drawer_proj_backend_aaa")
assert result["success"] is True
assert seeded_collection.count() == 3
def test_delete_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_delete_drawer
result = tool_delete_drawer("nonexistent_drawer")
assert result["success"] is False
def test_check_duplicate_handles_none_metadata(self, monkeypatch, config, kg):
"""tool_check_duplicate must tolerate None entries in the result lists
that ChromaDB 1.5.x returns for partially-flushed rows.
Previously ``meta = results["metadatas"][0][i]`` was unguarded and
raised ``AttributeError: 'NoneType' object has no attribute 'get'``
the moment the first matching drawer came back with None metadata —
surfacing to the MCP client as the uninformative
``"Duplicate check failed"`` because the broad ``except Exception``
wrapper swallows the real cause.
"""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
mock_col = MagicMock()
mock_col.query.return_value = {
"ids": [["d1", "d2"]],
"distances": [[0.05, 0.05]],
"metadatas": [[{"wing": "w", "room": "r"}, None]],
"documents": [["first doc", None]],
}
monkeypatch.setattr(mcp_server, "_get_collection", lambda: mock_col)
result = mcp_server.tool_check_duplicate("any content", threshold=0.5)
# Both entries land in matches (above threshold), None ones rendered
# with sentinel values rather than crashing the whole response.
assert result.get("is_duplicate") is True
assert len(result["matches"]) == 2
# The None-metadata entry falls back to sentinels.
none_entry = result["matches"][1]
assert none_entry["wing"] == "?"
assert none_entry["room"] == "?"
assert none_entry["content"] == ""
def test_check_duplicate(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_check_duplicate
# Exact match text from seeded_collection should be flagged
result = tool_check_duplicate(
"The authentication module uses JWT tokens for session management. "
"Tokens expire after 24 hours. Refresh tokens are stored in HttpOnly cookies.",
threshold=0.5,
)
assert result["is_duplicate"] is True
# Unrelated content should not be flagged
result = tool_check_duplicate(
"Black holes emit Hawking radiation at the event horizon.",
threshold=0.99,
)
assert result["is_duplicate"] is False
def test_check_duplicate_short_circuits_when_vector_disabled(self, monkeypatch):
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server,
"hnsw_capacity_status",
lambda *_args, **_kwargs: {"diverged": True, "message": "capacity mismatch"},
)
def fail_get_collection():
raise AssertionError("_get_collection must not run when vector search is disabled")
monkeypatch.setattr(mcp_server, "_get_collection", fail_get_collection)
result = mcp_server.tool_check_duplicate("content")
assert result["is_duplicate"] is False
assert result["vector_disabled"] is True
assert result["vector_disabled_reason"] == "capacity mismatch"
def test_checkpoint_files_items_and_writes_diary(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_checkpoint
result = tool_checkpoint(
items=[
{"wing": "w", "room": "decisions", "content": "Use PostgreSQL for storage."},
{"wing": "w", "room": "backend", "content": "Cache sessions in Redis."},
],
diary={"agent_name": "cursor-ide", "wing": "w", "entry": "SESSION|did.stuff|★"},
)
assert len(result["added"]) == 2
assert result["duplicates"] == []
assert result["errors"] == []
assert all(a["success"] for a in result["added"])
assert result["diary"]["success"] is True
def test_checkpoint_skips_semantic_duplicates(self, monkeypatch, config, kg):
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server,
"tool_check_duplicate",
lambda content, threshold=0.9: {
"is_duplicate": True,
"matches": [{"id": "x", "similarity": 0.95}],
},
)
called = {"add": False}
def _fail_add(**_kwargs):
called["add"] = True
return {"success": True}
monkeypatch.setattr(mcp_server, "tool_add_drawer", _fail_add)
result = mcp_server.tool_checkpoint(
items=[{"wing": "w", "room": "r", "content": "already known"}]
)
assert result["added"] == []
assert len(result["duplicates"]) == 1
assert called["add"] is False
def test_checkpoint_reports_malformed_items(self, monkeypatch, config, kg):
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
)
result = mcp_server.tool_checkpoint(items=[{"wing": "w", "room": "r"}, "not-a-dict"])
assert result["added"] == []
assert len(result["errors"]) == 2
def test_checkpoint_rejects_non_string_fields_without_calling_handlers(
self, monkeypatch, config, kg
):
"""A non-string content must be reported, never passed to the
single-item handlers where it would raise deep in sanitization."""
from mempalace import mcp_server
def _explode(*_a, **_k):
raise AssertionError("handlers must not run for malformed items")
monkeypatch.setattr(mcp_server, "tool_check_duplicate", _explode)
monkeypatch.setattr(mcp_server, "tool_add_drawer", _explode)
result = mcp_server.tool_checkpoint(
items=[{"wing": "w", "room": "r", "content": {"not": "a string"}}]
)
assert result["added"] == []
assert len(result["errors"]) == 1
assert "non-empty strings" in result["errors"][0]["error"]
def test_checkpoint_files_when_dedup_check_errors(self, monkeypatch, config, kg):
"""A dedup error is a genuine index failure (content is already
validated as a string); we still file rather than drop the memory."""
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server,
"tool_check_duplicate",
lambda *a, **k: {"error": "Duplicate check failed"},
)
filed = {}
def _add(**kwargs):
filed.update(kwargs)
return {"success": True, "drawer_id": "d1"}
monkeypatch.setattr(mcp_server, "tool_add_drawer", _add)
result = mcp_server.tool_checkpoint(
items=[{"wing": "w", "room": "r", "content": "keep me"}]
)
assert len(result["added"]) == 1
assert filed["content"] == "keep me"
def test_checkpoint_reports_malformed_diary(self, monkeypatch, config, kg):
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server, "tool_check_duplicate", lambda *a, **k: {"is_duplicate": False}
)
def _fail_diary(*_a, **_k):
raise AssertionError("diary_write must not run for malformed diary")
monkeypatch.setattr(mcp_server, "tool_diary_write", _fail_diary)
result = mcp_server.tool_checkpoint(items=[], diary={"agent_name": "x"})
assert "diary" not in result
assert any("diary entry" in e.get("error", "") for e in result["errors"])
def test_checkpoint_registered_in_tools(self):
from mempalace import mcp_server
assert "mempalace_checkpoint" in mcp_server.TOOLS
assert mcp_server.TOOLS["mempalace_checkpoint"]["handler"] is mcp_server.tool_checkpoint
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("drawer_proj_backend_aaa")
assert result["drawer_id"] == "drawer_proj_backend_aaa"
assert result["wing"] == "project"
assert result["room"] == "backend"
assert "JWT tokens" in result["content"]
def test_get_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("nonexistent_drawer")
assert "error" in result
def test_get_drawer_does_not_leak_absolute_source_file_path(
self, monkeypatch, config, palace_path, collection, kg
):
"""tool_get_drawer must not expose the absolute filesystem path
that the miners write into ``source_file``. Same threat class as
the palace_path leak in mempalace_status: in nested-agent or
multi-server MCP topologies the client is a separate trust
domain, and the directory layout of the host has no documented
client-side use. Basename is enough for citation."""
_patch_mcp_server(monkeypatch, config, kg)
secret_dir = "/private/home/alice/secret-research/2026"
absolute_source = f"{secret_dir}/notes.md"
collection.add(
ids=["drawer_leak_probe"],
documents=["verbatim drawer body for leak probe"],
metadatas=[
{
"wing": "research",
"room": "notes",
"source_file": absolute_source,
"chunk_index": 0,
"added_by": "miner",
"filed_at": "2026-05-03T00:00:00",
}
],
)
from mempalace.mcp_server import tool_get_drawer
result = tool_get_drawer("drawer_leak_probe")
assert result["drawer_id"] == "drawer_leak_probe"
assert result["metadata"]["source_file"] == "notes.md"
# Defense-in-depth: no field anywhere in the response should
# contain the absolute path or its parent directory.
serialized = json.dumps(result)
assert absolute_source not in serialized
assert secret_dir not in serialized
def test_list_drawers(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers()
assert result["count"] == 4
assert len(result["drawers"]) == 4
def test_list_drawers_with_wing_filter(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project")
assert result["count"] == 3
assert all(d["wing"] == "project" for d in result["drawers"])
def test_list_drawers_with_room_filter(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(wing="project", room="backend")
assert result["count"] == 2
assert all(d["room"] == "backend" for d in result["drawers"])
def test_list_drawers_pagination(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(limit=2, offset=0)
assert result["count"] == 2
assert result["limit"] == 2
assert result["offset"] == 0
def test_list_drawers_negative_offset_clamped(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(offset=-5)
assert result["offset"] == 0
def test_list_drawers_since_filter_inclusive(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# seeded filed_at values: 2026-01-01..2026-01-04; since is inclusive.
result = tool_list_drawers(since="2026-01-03")
assert result["total"] == 2
assert result["count"] == 2
filed = sorted(d["metadata"]["filed_at"] for d in result["drawers"])
assert filed == ["2026-01-03T00:00:00", "2026-01-04T00:00:00"]
def test_list_drawers_before_filter_exclusive(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# before is exclusive: 2026-01-03 keeps only 01 and 02.
result = tool_list_drawers(before="2026-01-03")
assert result["total"] == 2
filed = sorted(d["metadata"]["filed_at"] for d in result["drawers"])
assert filed == ["2026-01-01T00:00:00", "2026-01-02T00:00:00"]
def test_list_drawers_since_and_before_window(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# [since, before): 02 and 03 kept, 01 below, 04 at/above the bound.
result = tool_list_drawers(since="2026-01-02", before="2026-01-04")
assert result["total"] == 2
filed = sorted(d["metadata"]["filed_at"] for d in result["drawers"])
assert filed == ["2026-01-02T00:00:00", "2026-01-03T00:00:00"]
def test_list_drawers_date_window_single_day(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# since inclusive + before exclusive isolates exactly 2026-01-02.
result = tool_list_drawers(since="2026-01-02", before="2026-01-03")
assert result["total"] == 1
assert result["drawers"][0]["metadata"]["filed_at"] == "2026-01-02T00:00:00"
def test_list_drawers_date_filter_combines_with_wing(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# project wing = 01,02,03; since 2026-01-02 narrows to 02,03.
result = tool_list_drawers(wing="project", since="2026-01-02")
assert result["total"] == 2
assert all(d["wing"] == "project" for d in result["drawers"])
def test_list_drawers_no_date_filter_unchanged(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# Omitting since/before leaves the full set (regression guard).
assert tool_list_drawers()["total"] == 4
def test_list_drawers_rejects_invalid_since(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(since="not-a-date")
assert "error" in result
assert "since" in result["error"]
def test_list_drawers_rejects_invalid_before(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
result = tool_list_drawers(before="2026-99-99")
assert "error" in result
assert "before" in result["error"]
def test_list_drawers_rejects_inverted_window(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# since must be earlier than before; inverted bounds are a clear error,
# not a silently empty result.
result = tool_list_drawers(since="2026-06-01", before="2026-01-01")
assert "error" in result
assert "since" in result["error"]
assert "before" in result["error"]
def test_list_drawers_excludes_undated_drawer_when_filtered(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# A drawer with no filed_at is present unfiltered but excluded once a
# date bound is active (its age cannot be confirmed in-window).
seeded_collection.add(
ids=["drawer_no_filed_at"],
documents=["A drawer without a filed_at timestamp."],
metadatas=[{"wing": "project", "room": "backend"}],
)
assert tool_list_drawers()["total"] == 5
filtered = tool_list_drawers(since="2026-01-01")
ids = [d["drawer_id"] for d in filtered["drawers"]]
assert "drawer_no_filed_at" not in ids
assert filtered["total"] == 4
def test_list_drawers_date_filter_paginates_on_filtered_total(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_drawers
# window [01-01, 01-04) keeps 01, 02, 03; pagination runs on that
# filtered total, not the grand total of 4.
page1 = tool_list_drawers(since="2026-01-01", before="2026-01-04", limit=2, offset=0)
page2 = tool_list_drawers(since="2026-01-01", before="2026-01-04", limit=2, offset=2)
assert page1["total"] == 3
assert page1["count"] == 2
assert page2["total"] == 3
assert page2["count"] == 1
def test_update_drawer_content(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer, tool_get_drawer
result = tool_update_drawer(
"drawer_proj_backend_aaa", content="Updated content about auth."
)
assert result["success"] is True
fetched = tool_get_drawer("drawer_proj_backend_aaa")
assert fetched["content"] == "Updated content about auth."
def test_update_drawer_wing_and_room(
self, monkeypatch, config, palace_path, seeded_collection, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa", wing="new_wing", room="new_room")
assert result["success"] is True
assert result["wing"] == "new_wing"
assert result["room"] == "new_room"
def test_update_drawer_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("nonexistent_drawer", content="hello")
assert result["success"] is False
def test_update_drawer_noop(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_update_drawer
result = tool_update_drawer("drawer_proj_backend_aaa")
assert result["success"] is True
assert result.get("noop") is True
def test_tool_create_tunnel_preserves_hyphenated_wings(self, monkeypatch, tmp_path):
"""Regression for #1504: ``tool_create_tunnel`` stores the wing slug
verbatim, and both hyphen and underscore queries find the result."""
from mempalace import mcp_server, palace_graph
tunnel_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_get_tunnel_file", lambda *a, **kw: str(tunnel_file))
monkeypatch.setattr(
palace_graph,
"_legacy_tunnel_file",
lambda: str(tmp_path / "legacy-tunnels.json"),
)
monkeypatch.setattr(palace_graph, "_get_collection", lambda *a, **kw: None)
t = mcp_server.tool_create_tunnel(
source_wing="other-wing",
source_room="r1",
target_wing="my-wing",
target_room="r2",
label="hyphen preservation",
)
assert t["source"]["wing"] == "other-wing"
assert t["target"]["wing"] == "my-wing"
assert len(mcp_server.tool_list_tunnels(wing="my-wing")) == 1
assert len(mcp_server.tool_list_tunnels(wing="my_wing")) == 1
def test_tool_create_tunnel_surfaces_value_error(self, monkeypatch):
"""Regression for #1473: a ValueError from create_tunnel (e.g. a
missing room) must be returned to the caller as a clear error,
not escape and get wrapped as the opaque 'Internal tool error'."""
from mempalace import mcp_server
msg = "Target room 'does-not-exist-probe' does not exist in wing 'wing_minerva'"
def _raise(*args, **kwargs):
raise ValueError(msg)
monkeypatch.setattr(mcp_server, "create_tunnel", _raise)
result = mcp_server.tool_create_tunnel(
source_wing="wing_minerva",
source_room="fx-invariants",
target_wing="wing_minerva",
target_room="does-not-exist-probe",
)
assert result == {"error": msg}
# ── hallway MCP tools (mirror the tunnel pattern) ──
def _seed_hallways(self, monkeypatch, tmp_path):
"""Point hallways resolvers at a tmp file and seed two records."""
from mempalace import hallways
hallway_file = tmp_path / "hallways.json"
monkeypatch.setattr(hallways, "_get_hallway_file", lambda *a, **kw: str(hallway_file))
monkeypatch.setattr(
hallways,
"_legacy_hallway_file",
lambda: str(tmp_path / "legacy-hallways.json"),
)
seeded = [
{
"id": "hallway_wing_a_X_Y_aaaa",
"wing": "wing_a",
"entity_a": "X",
"entity_b": "Y",
"co_occurrence_count": 3,
"rooms": ["room1"],
},
{
"id": "hallway_wing_b_X_Z_bbbb",
"wing": "wing_b",
"entity_a": "X",
"entity_b": "Z",
"co_occurrence_count": 1,
"rooms": ["room2"],
},
]
hallways._save_hallways(seeded)
return seeded
def test_tool_list_hallways_returns_all_without_filter(self, monkeypatch, tmp_path):
"""tool_list_hallways with no wing returns every record."""
from mempalace import mcp_server
seeded = self._seed_hallways(monkeypatch, tmp_path)
result = mcp_server.tool_list_hallways()
assert isinstance(result, list)
assert len(result) == len(seeded)
ids = {h["id"] for h in result}
assert ids == {h["id"] for h in seeded}
def test_tool_list_hallways_filters_by_wing(self, monkeypatch, tmp_path):
"""tool_list_hallways with wing returns only that wing's records."""
from mempalace import mcp_server
self._seed_hallways(monkeypatch, tmp_path)
result = mcp_server.tool_list_hallways(wing="wing_a")
assert len(result) == 1
assert result[0]["wing"] == "wing_a"
def test_tool_list_hallways_rejects_invalid_wing_name(self, monkeypatch, tmp_path):
"""Invalid wing names go through _sanitize_optional_name and return a
structured error rather than crashing — mirrors tool_list_tunnels."""
from mempalace import mcp_server
self._seed_hallways(monkeypatch, tmp_path)
# Forward-slash is not a valid name character per sanitize_name.
result = mcp_server.tool_list_hallways(wing="wing/with/slashes")
assert isinstance(result, dict)
assert "error" in result
def test_tool_delete_hallway_removes_existing_record(self, monkeypatch, tmp_path):
"""tool_delete_hallway removes the record and returns {deleted: True}."""
from mempalace import mcp_server
seeded = self._seed_hallways(monkeypatch, tmp_path)
target_id = seeded[0]["id"]
result = mcp_server.tool_delete_hallway(hallway_id=target_id)
assert result == {"deleted": True}
remaining = mcp_server.tool_list_hallways()
assert target_id not in {h["id"] for h in remaining}
def test_tool_delete_hallway_unknown_id_returns_false(self, monkeypatch, tmp_path):
"""Deleting an ID that doesn't exist returns {deleted: False} without error."""
from mempalace import mcp_server
self._seed_hallways(monkeypatch, tmp_path)
result = mcp_server.tool_delete_hallway(hallway_id="hallway_does_not_exist")
assert result == {"deleted": False}
def test_tool_delete_hallway_requires_string_id(self):
"""Missing or non-string hallway_id surfaces a structured error."""
from mempalace import mcp_server
assert mcp_server.tool_delete_hallway(hallway_id="") == {"error": "hallway_id is required"}
assert mcp_server.tool_delete_hallway(hallway_id=None) == {
"error": "hallway_id is required"
}
def test_hallway_tools_registered_in_tools_registry(self):
"""Both new tools must appear in the public TOOLS registry so MCP clients can dispatch them."""
from mempalace import mcp_server
assert "mempalace_list_hallways" in mcp_server.TOOLS
assert "mempalace_delete_hallway" in mcp_server.TOOLS
assert (
mcp_server.TOOLS["mempalace_list_hallways"]["handler"] is mcp_server.tool_list_hallways
)
assert (
mcp_server.TOOLS["mempalace_delete_hallway"]["handler"]
is mcp_server.tool_delete_hallway
)
def test_add_drawer_normal_content_single_drawer(self, monkeypatch, config, palace_path, kg):
"""Regression catch: content below CHUNK_SIZE produces exactly
one drawer with ``chunks == 1``. Pre-#1539 contract preserved."""
_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="w", room="r", content="Short content well under chunk_size.")
assert result["success"] is True
assert result["chunks"] == 1
assert "chunk_ids" not in result
_client2, col = _get_collection(palace_path)
del _client2
assert col.count() == 1
assert col.get()["ids"] == [result["drawer_id"]]
def test_add_drawer_oversized_content_chunked(self, monkeypatch, config, palace_path, kg):
"""Regression for #1539: content far above chunk_size must be
sliced into bounded per-chunk drawers, each linked by a
``parent_drawer_id`` metadata field. No stored document may
exceed the configured chunk_size."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
oversized = "X" * 10000
result = tool_add_drawer(wing="w", room="r", content=oversized)
assert result["success"] is True
assert result["chunks"] > 1
assert "chunk_ids" in result and len(result["chunk_ids"]) == result["chunks"]
_client2, col = _get_collection(palace_path)
del _client2
stored = col.get()
max_doc = max(len(d) for d in stored["documents"])
assert max_doc <= config.chunk_size, (
f"no stored document may exceed chunk_size={config.chunk_size}; got max={max_doc}"
)
# Chroma does not guarantee insertion order on a bare ``get()``;
# sort by ``chunk_index`` before joining so the verbatim check
# is deterministic.
ordered = sorted(
zip(stored["metadatas"], stored["documents"]),
key=lambda pair: pair[0]["chunk_index"],
)
assert "".join(doc for _meta, doc in ordered) == oversized
parent_ids = {m.get("parent_drawer_id") for m in stored["metadatas"]}
assert parent_ids == {result["drawer_id"]}, (
f"all chunks must share one parent_drawer_id; got {parent_ids}"
)
def test_add_drawer_oversized_idempotency_skips_duplicate_chunk_writes(
self, monkeypatch, config, palace_path, kg
):
"""Re-calling with identical oversized content must not duplicate
any drawer. Idempotency on the chunked path probes the last
chunk id (its presence implies the whole batch committed) and
also the legacy logical drawer_id so a pre-#1539 single-row
write under the same logical id does not get co-resident chunk
siblings on the next call."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
oversized = "Y" * 5000
r1 = tool_add_drawer(wing="w", room="r", content=oversized)
assert r1["success"] is True and r1["chunks"] > 1
r2 = tool_add_drawer(wing="w", room="r", content=oversized)
assert r2["success"] is True
assert r2.get("reason") == "already_exists"
_client2, col = _get_collection(palace_path)
del _client2
assert col.count() == r1["chunks"]
# The probe must succeed against the last chunk id (atomicity
# signal), and no row must be stored under the logical id.
last_chunk = r1["chunk_ids"][-1]
assert col.get(ids=[last_chunk])["ids"] == [last_chunk]
assert col.get(ids=[r1["drawer_id"]])["ids"] == []
def test_add_drawer_chunk_metadata_carries_parent_link(
self, monkeypatch, config, palace_path, kg
):
"""Every chunk produced from oversized content must carry both
``chunk_index`` (0..N-1) and ``parent_drawer_id`` matching the
logical group handle returned to the caller."""
_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="w", room="r", content="Q" * 3500)
assert result["success"] is True and result["chunks"] > 1
_client2, col = _get_collection(palace_path)
del _client2
stored = col.get()
indices = sorted(m["chunk_index"] for m in stored["metadatas"])
assert indices == list(range(len(indices)))
for meta in stored["metadatas"]:
assert meta.get("parent_drawer_id") == result["drawer_id"]
def test_add_drawer_boundary_exact_chunk_size_stays_single(
self, monkeypatch, config, palace_path, kg
):
"""The ``<= chunk_size`` predicate must include the boundary:
content of exactly chunk_size chars stays a single drawer, not
an off-by-one chunked write."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
boundary = "Z" * config.chunk_size
result = tool_add_drawer(wing="w", room="r", content=boundary)
assert result["success"] is True
assert result["chunks"] == 1
assert "chunk_ids" not in result
def test_add_drawer_chunked_logical_id_fetches_deletes_and_lists_as_one(
monkeypatch, config, palace_path, kg
):
"""Chunk rows are internal storage; MCP tools operate on the logical id."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import (
tool_add_drawer,
tool_delete_drawer,
tool_get_drawer,
tool_list_drawers,
)
result = tool_add_drawer(wing="w", room="r", content="P" * 4000)
assert result["success"] is True
assert result["chunks"] > 1
logical_id = result["drawer_id"]
fetched = tool_get_drawer(logical_id)
assert fetched["drawer_id"] == logical_id
assert fetched["content"] == "P" * 4000
assert fetched["chunks"] == result["chunks"]
assert fetched["chunk_ids"] == result["chunk_ids"]
listed = tool_list_drawers(wing="w", room="r")
assert listed["total"] == 1
assert listed["count"] == 1
assert listed["drawers"][0]["drawer_id"] == logical_id
assert listed["drawers"][0]["chunks"] == result["chunks"]
deleted = tool_delete_drawer(logical_id)
assert deleted["success"] is True
assert deleted["chunks_deleted"] == result["chunks"]
missing = tool_get_drawer(logical_id)
assert "error" in missing
assert "not found" in missing["error"].lower()
def test_update_drawer_chunked_logical_id_rewrites_group(monkeypatch, config, palace_path, kg):
"""Updating the returned logical id rewrites the underlying chunk group."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import (
tool_add_drawer,
tool_get_drawer,
tool_list_drawers,
tool_update_drawer,
)
result = tool_add_drawer(wing="old", room="old_room", content="A" * 2600)
assert result["success"] is True
assert result["chunks"] > 1
logical_id = result["drawer_id"]
updated = tool_update_drawer(
logical_id,
content="B" * 1800,
wing="new",
room="new_room",
)
assert updated["success"] is True
assert updated["drawer_id"] == logical_id
fetched = tool_get_drawer(logical_id)
assert fetched["drawer_id"] == logical_id
assert fetched["content"] == "B" * 1800
assert fetched["wing"] == "new"
assert fetched["room"] == "new_room"
listed = tool_list_drawers(wing="new", room="new_room")
assert listed["total"] == 1
assert listed["drawers"][0]["drawer_id"] == logical_id
# ── Delete by source (#1722) ────────────────────────────────────────────
class TestDeleteBySource:
"""``tool_delete_by_source`` — bulk cleanup of benchmark/test contamination (#1722)."""
def _seed(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_add_drawer
# Two drawers from a "benchmark" source, one from real user data.
tool_add_drawer(
wing="bench",
room="general",
content="ShareGPT yoga retreat conversation noise number one.",
source_file="results_mempal_hybrid_v4_session_1.jsonl",
)
tool_add_drawer(
wing="bench",
room="general",
content="ShareGPT coding job description noise number two.",
source_file="results_mempal_hybrid_v4_session_1.jsonl",
)
tool_add_drawer(
wing="clients",
room="webdesign",
content="GG Sauna Dachdecker real client memory that must survive.",
source_file="notes/clients.md",
)
def _seed_closets(self, palace_path):
"""Seed the AAAK index (closets) directly.
``tool_add_drawer`` never builds closets — those are a miner-side
artifact — so to exercise the closet purge we add them straight to the
collection, keyed by the same ``source_file`` the drawers use: two for
the benchmark source, one for the real-client source.
"""
from mempalace.palace import get_closets_collection
closets_col = get_closets_collection(palace_path, create=True)
closets_col.add(
ids=["bench_closet_01", "bench_closet_02", "client_closet_01"],
documents=[
"topic: yoga retreat | coding job",
"topic: more bench noise",
"topic: GG Sauna client",
],
metadatas=[
{"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
{"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
{"source_file": "notes/clients.md"},
],
)
return closets_col
def test_dry_run_reports_count_without_deleting(self, monkeypatch, config, palace_path, kg):
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import tool_delete_by_source, tool_status
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl")
assert result["success"] is True
assert result["dry_run"] is True
assert result["match_count"] == 2
assert {"wing": "bench", "room": "general"} in result["sample"]
# Nothing removed — all three drawers still present.
assert tool_status()["total_drawers"] == 3
def test_dry_run_reports_closet_match_count(self, monkeypatch, config, palace_path, kg):
"""Dry run surfaces the closet blast radius (#1722) without deleting."""
self._seed(monkeypatch, config, palace_path, kg)
closets_col = self._seed_closets(palace_path)
from mempalace.mcp_server import tool_delete_by_source
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl")
assert result["dry_run"] is True
assert result["closet_match_count"] == 2
# Nothing removed — all three closets still present.
assert len(closets_col.get(include=[])["ids"]) == 3
def test_commit_deletes_only_matching_source(self, monkeypatch, config, palace_path, kg):
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import tool_delete_by_source, tool_status
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl", dry_run=False)
assert result["success"] is True
assert result["dry_run"] is False
assert result["deleted"] == 2
# Only the real client drawer remains.
assert tool_status()["total_drawers"] == 1
def test_commit_purges_matching_closets(self, monkeypatch, config, palace_path, kg):
"""Deleting by source purges the matching closets too, so the AAAK
index keeps no stale pointers at the now-deleted drawers (#1722)."""
self._seed(monkeypatch, config, palace_path, kg)
closets_col = self._seed_closets(palace_path)
from mempalace.mcp_server import tool_delete_by_source
result = tool_delete_by_source("results_mempal_hybrid_v4_session_1.jsonl", dry_run=False)
assert result["success"] is True
assert result["deleted"] == 2
assert result["closets_deleted"] == 2
# The two benchmark closets are gone; the real-client closet survives.
remaining = closets_col.get(include=["metadatas"])
sources = {m["source_file"] for m in remaining["metadatas"]}
assert sources == {"notes/clients.md"}
def test_no_match_is_idempotent_not_error(self, monkeypatch, config, palace_path, kg):
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import tool_delete_by_source, tool_status
result = tool_delete_by_source("does/not/exist.jsonl", dry_run=False)
assert result["success"] is True
assert result["deleted"] == 0
assert tool_status()["total_drawers"] == 3
def test_empty_source_file_rejected(self, monkeypatch, config, palace_path, kg):
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import tool_delete_by_source
result = tool_delete_by_source(" ", dry_run=False)
assert result["success"] is False
assert "non-empty" in result["error"]
def test_non_string_source_rejected(self, monkeypatch, config, palace_path, kg):
"""A non-string source_file must return a clean error, not AttributeError."""
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import tool_delete_by_source
result = tool_delete_by_source(123, dry_run=False)
assert result["success"] is False
assert "non-empty" in result["error"]
def test_matches_after_surrogate_normalization(self, monkeypatch, config, palace_path, kg):
"""source_file is stripped of lone surrogates on both ingest and delete,
so a path that arrived via a cp1252 stdin (#1488) still matches."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import (
tool_add_drawer,
tool_delete_by_source,
tool_status,
)
# Lone low surrogate embedded in the path — add_drawer strips it.
raw_source = "noise\udce9_data.jsonl"
tool_add_drawer(
wing="bench",
room="general",
content="benchmark noise from a non-ASCII path",
source_file=raw_source,
)
assert tool_status()["total_drawers"] == 1
# Deleting with the same raw (un-stripped) string must still match.
result = tool_delete_by_source(raw_source, dry_run=False)
assert result["success"] is True
assert result["deleted"] == 1
assert tool_status()["total_drawers"] == 0
def test_registered_and_dispatchable(self, monkeypatch, config, palace_path, kg):
self._seed(monkeypatch, config, palace_path, kg)
from mempalace.mcp_server import handle_request
# Listed in tools/list
listed = handle_request({"method": "tools/list", "id": 1, "params": {}})
names = {t["name"] for t in listed["result"]["tools"]}
assert "mempalace_delete_by_source" in names
# Dispatches and defaults to dry-run (no destructive side effect)
resp = handle_request(
{
"method": "tools/call",
"id": 2,
"params": {
"name": "mempalace_delete_by_source",
"arguments": {"source_file": "results_mempal_hybrid_v4_session_1.jsonl"},
},
}
)
content = json.loads(resp["result"]["content"][0]["text"])
assert content["dry_run"] is True
assert content["match_count"] == 2
# ── KG Tools ────────────────────────────────────────────────────────────
class TestKGTools:
def test_kg_add(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="Alice",
predicate="likes",
object="coffee",
valid_from="2025-01-01",
)
assert result["success"] is True
def test_kg_query(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_query
result = tool_kg_query(entity="Max")
assert result["count"] > 0
def test_kg_invalidate(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_invalidate
result = tool_kg_invalidate(
subject="Max",
predicate="does",
object="chess",
ended="2026-03-01",
)
assert result["success"] is True
# Regression #1314: response must echo the actual ended date,
# not silently drop it and return the literal string "today".
assert result["ended"] == "2026-03-01"
def test_kg_supersede(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_supersede
kg.add_triple("Bot", "uses_model", "old", valid_from="2026-05-01")
result = tool_kg_supersede(
subject="Bot",
predicate="uses_model",
old_object="old",
new_object="new",
at="2026-06-02",
)
assert result["success"] is True
assert result["superseded"] == "old"
models = [
f["object"]
for f in kg.query_entity("Bot", as_of="2026-06-02", direction="outgoing")
if f["predicate"] == "uses_model"
]
assert models == ["new"]
def test_kg_add_forwards_valid_to(self, monkeypatch, config, palace_path, kg):
"""Regression #1314 case 1: valid_to must round-trip through kg_add."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="_test_temporal",
predicate="had_value",
object="probe",
valid_from="2026-01-01",
valid_to="2026-04-28",
)
assert result["success"] is True
facts = kg.query_entity("_test_temporal")
assert len(facts) == 1
assert facts[0]["valid_from"] == "2026-01-01"
assert facts[0]["valid_to"] == "2026-04-28"
# An already-ended fact must not be reported as still current.
assert facts[0]["current"] is False
def test_kg_add_forwards_source_provenance(self, monkeypatch, config, palace_path, kg):
"""Regression #1314 case 3: source_file / source_drawer_id reach storage."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="operating-verb",
predicate="candidate",
object="husbandry",
valid_from="2026-04-28",
source_closet="closet-42",
source_file="docs/decisions.md",
source_drawer_id="drawer_abc123",
)
assert result["success"] is True
triple_id = result["triple_id"]
# Read raw row to verify all provenance columns persisted.
with kg._lock:
row = (
kg._conn()
.execute(
"SELECT source_closet, source_file, source_drawer_id FROM triples WHERE id = ?",
(triple_id,),
)
.fetchone()
)
assert row is not None
assert row["source_closet"] == "closet-42"
assert row["source_file"] == "docs/decisions.md"
assert row["source_drawer_id"] == "drawer_abc123"
def test_kg_invalidate_returns_actual_ended_date(
self, monkeypatch, config, palace_path, seeded_kg
):
"""Regression #1314 case 2: response reports the resolved date, not 'today'."""
from datetime import date as _date
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_invalidate
# Caller-supplied date round-trips into the response.
explicit = tool_kg_invalidate(
subject="Max",
predicate="does",
object="swimming",
ended="2026-04-28",
)
assert explicit["ended"] == "2026-04-28"
# Caller-omitted date resolves to today's ISO date — never the
# literal string "today" the buggy implementation used to return.
implicit = tool_kg_invalidate(
subject="Max",
predicate="loves",
object="Chess",
)
assert implicit["ended"] != "today"
assert implicit["ended"] == _date.today().isoformat()
def test_kg_timeline(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_timeline
result = tool_kg_timeline(entity="Alice")
assert result["count"] > 0
def test_kg_stats(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_stats
result = tool_kg_stats()
assert result["entities"] >= 4
# --- Date validation at the MCP boundary (issue #1164) ---
def test_kg_add_rejects_invalid_valid_from(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="Alice",
predicate="likes",
object="coffee",
valid_from="Jan 2025",
)
assert result["success"] is False
assert "valid_from" in result["error"]
assert "ISO-8601" in result["error"]
def test_kg_query_rejects_invalid_as_of(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_query
result = tool_kg_query(entity="Max", as_of="March 2026")
assert "error" in result
assert "as_of" in result["error"]
def test_kg_invalidate_rejects_invalid_ended(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_invalidate
result = tool_kg_invalidate(
subject="Max",
predicate="does",
object="chess",
ended="yesterday",
)
assert result["success"] is False
assert "ended" in result["error"]
def test_kg_query_rejects_partial_iso_dates(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_query
# Partial ISO dates are rejected: KG queries compare TEXT dates
# lexicographically, so "2026-01-01" <= "2026" is False, which
# silently excludes facts. Reject at the boundary — only YYYY-MM-DD
# produces correct results.
for value in ("2026", "2026-03"):
result = tool_kg_query(entity="Max", as_of=value)
assert "error" in result, f"accepted partial date {value!r}: {result}"
# Full ISO-8601 dates still pass.
result = tool_kg_query(entity="Max", as_of="2026-03-15")
assert "error" not in result, f"rejected valid date: {result}"
def test_kg_add_accepts_datetime_valid_from(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
result = mcp_server.tool_kg_add(
"Alice",
"works_at",
"Acme",
valid_from="2026-05-06T14:23:00Z",
)
assert result["success"] is True
facts = kg.query_entity("Alice", direction="outgoing")
fact = next(r for r in facts if r["predicate"] == "works_at" and r["object"] == "Acme")
assert fact["valid_from"] == "2026-05-06T14:23:00Z"
def test_kg_add_accepts_datetime_valid_to(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
result = mcp_server.tool_kg_add(
"Alice",
"worked_at",
"OldCo",
valid_from="2026-05-06T14:00:00Z",
valid_to="2026-05-06T15:00:00Z",
)
assert result["success"] is True
facts = kg.query_entity("Alice", direction="outgoing")
fact = next(r for r in facts if r["predicate"] == "worked_at" and r["object"] == "OldCo")
assert fact["valid_from"] == "2026-05-06T14:00:00Z"
assert fact["valid_to"] == "2026-05-06T15:00:00Z"
def test_kg_query_accepts_datetime_as_of(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
kg.add_triple(
"Alice",
"works_at",
"Acme",
valid_from="2026-05-06T14:00:00Z",
)
from mempalace import mcp_server
result = mcp_server.tool_kg_query(
"Alice",
as_of="2026-05-06T14:23:00Z",
direction="outgoing",
)
assert "error" not in result
assert result["as_of"] == "2026-05-06T14:23:00Z"
assert result["count"] == 1
assert result["facts"][0]["object"] == "Acme"
def test_kg_invalidate_accepts_datetime_ended(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
kg.add_triple(
"Alice",
"works_at",
"Acme",
valid_from="2026-05-06T14:00:00Z",
)
from mempalace import mcp_server
result = mcp_server.tool_kg_invalidate(
"Alice",
"works_at",
"Acme",
ended="2026-05-06T14:23:00Z",
)
assert result["success"] is True
assert result["ended"] == "2026-05-06T14:23:00Z"
facts = kg.query_entity("Alice", direction="outgoing")
fact = next(r for r in facts if r["predicate"] == "works_at" and r["object"] == "Acme")
assert fact["valid_to"] == "2026-05-06T14:23:00Z"
def test_kg_add_rejects_non_canonical_datetimes(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
invalid_values = [
"2026-05-06T14:23:00+02:00",
"2026-05-06T14:23:00-05:30",
"2026-05-06T14:23:00.123Z",
"2026-05-06 14:23:00",
"2026-05-06T14:23:00",
]
for value in invalid_values:
result = mcp_server.tool_kg_add(
"Alice",
"works_at",
"Acme",
valid_from=value,
)
assert result["success"] is False, value
assert "valid_from" in result["error"]
assert "YYYY-MM-DDTHH:MM:SSZ" in result["error"]
def test_kg_query_rejects_non_canonical_datetime_as_of(
self, monkeypatch, config, palace_path, kg
):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
invalid_values = [
"2026-05-06T14:23:00+02:00",
"2026-05-06T14:23:00-05:30",
"2026-05-06T14:23:00.123Z",
"2026-05-06 14:23:00",
"2026-05-06T14:23:00",
]
for value in invalid_values:
result = mcp_server.tool_kg_query(
"Alice",
as_of=value,
direction="outgoing",
)
assert "error" in result, value
assert "as_of" in result["error"]
assert "YYYY-MM-DDTHH:MM:SSZ" in result["error"]
def test_kg_invalidate_rejects_non_canonical_ended(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
kg.add_triple(
"Alice",
"works_at",
"Acme",
valid_from="2026-05-06T14:00:00Z",
)
from mempalace import mcp_server
invalid_values = [
"2026-05-06T14:23:00+02:00",
"2026-05-06T14:23:00-05:30",
"2026-05-06T14:23:00.123Z",
"2026-05-06 14:23:00",
"2026-05-06T14:23:00",
]
for value in invalid_values:
result = mcp_server.tool_kg_invalidate(
"Alice",
"works_at",
"Acme",
ended=value,
)
assert result["success"] is False, value
assert "ended" in result["error"]
assert "YYYY-MM-DDTHH:MM:SSZ" in result["error"]
def test_kg_add_rejects_timezone_offset_datetime(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
result = mcp_server.tool_kg_add(
"Alice",
"works_at",
"Acme",
valid_from="2026-05-06T14:23:00+02:00",
)
assert result["success"] is False
assert "valid_from" in result["error"]
assert "YYYY-MM-DDTHH:MM:SSZ" in result["error"]
# ── Diary Tools ─────────────────────────────────────────────────────────
class TestDiaryTools:
def test_diary_write_and_read(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_write, tool_diary_read
w = tool_diary_write(
agent_name="TestAgent",
entry="Today we discussed authentication patterns.",
topic="architecture",
)
assert w["success"] is True
# agent_name is normalized to lowercase on write (#1243).
assert w["agent"] == "testagent"
r = tool_diary_read(agent_name="TestAgent")
assert r["total"] == 1
assert r["entries"][0]["topic"] == "architecture"
assert "authentication" in r["entries"][0]["content"]
def test_diary_read_empty(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_read
r = tool_diary_read(agent_name="Nobody")
assert r["entries"] == []
def test_diary_write_same_second_shared_prefix_no_collision(
self, monkeypatch, config, palace_path, kg
):
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace import mcp_server
class FrozenDateTime:
calls = [
datetime(2026, 4, 13, 22, 15, 30, 123456),
datetime(2026, 4, 13, 22, 15, 30, 123457),
]
fallback = datetime(2026, 4, 13, 22, 15, 30, 123457)
@classmethod
def now(cls):
if cls.calls:
return cls.calls.pop(0)
return cls.fallback
monkeypatch.setattr(mcp_server, "datetime", FrozenDateTime)
from mempalace.mcp_server import tool_diary_read, tool_diary_write
entry1 = "A" * 50 + " entry one"
entry2 = "A" * 50 + " entry two"
result1 = tool_diary_write(agent_name="TestAgent", entry=entry1, topic="status")
result2 = tool_diary_write(agent_name="TestAgent", entry=entry2, topic="status")
assert result1["success"] is True
assert result2["success"] is True
assert result1["entry_id"] != result2["entry_id"]
read_result = tool_diary_read(agent_name="TestAgent")
contents = [entry["content"] for entry in read_result["entries"]]
assert read_result["total"] == 2
assert entry1 in contents
assert entry2 in contents
def test_diary_read_empty_wing_spans_all_wings(self, monkeypatch, config, palace_path, kg):
"""diary_read(wing='') must return entries from every wing this agent
wrote to. Hooks write to project-derived wings (#659); a reader that
silos by default wing would never see those entries."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_read, tool_diary_write
w1 = tool_diary_write(
agent_name="TestAgent",
entry="default-wing entry",
topic="general",
)
w2 = tool_diary_write(
agent_name="TestAgent",
entry="project-wing entry",
topic="general",
wing="wing_someproject",
)
assert w1["success"] and w2["success"]
# Empty wing → return both entries
r = tool_diary_read(agent_name="TestAgent", wing="")
assert r["total"] == 2
contents = {e["content"] for e in r["entries"]}
assert "default-wing entry" in contents
assert "project-wing entry" in contents
# Explicit wing → return only that wing's entries
r_scoped = tool_diary_read(agent_name="TestAgent", wing="wing_someproject")
assert r_scoped["total"] == 1
assert r_scoped["entries"][0]["content"] == "project-wing entry"
def test_diary_read_case_insensitive_agent(self, monkeypatch, config, palace_path, kg):
"""Regression for #1243: diary_read must be case-insensitive over
agent_name. Writing as "Claude" and reading as "claude" (or vice
versa) must surface the same entries — sanitize_name preserved
case, which silently dropped reads when the agent name's casing
differed from the write."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_read, tool_diary_write
# Write as "Claude" → read as "claude" should match.
w1 = tool_diary_write(
agent_name="Claude",
entry="entry written as Claude",
topic="general",
)
assert w1["success"]
r1 = tool_diary_read(agent_name="claude")
assert "entries" in r1, r1
contents1 = {e["content"] for e in r1["entries"]}
assert "entry written as Claude" in contents1
# Write as "CLAUDE" → read as "Claude" should also match the
# same agent. After normalization both writes target the same
# lowercase agent identity, so both entries are returned.
w2 = tool_diary_write(
agent_name="CLAUDE",
entry="entry written as CLAUDE",
topic="general",
)
assert w2["success"]
r2 = tool_diary_read(agent_name="Claude")
contents2 = {e["content"] for e in r2["entries"]}
assert "entry written as Claude" in contents2
assert "entry written as CLAUDE" in contents2
# The stored agent metadata is the lowercase form, and the
# default wing is derived from that lowercase form too.
assert w1["agent"] == "claude"
assert w2["agent"] == "claude"
# ── #1539: oversized-entry chunking ────────────────────────────
def test_diary_write_normal_entry_single_drawer(self, monkeypatch, config, palace_path, kg):
"""Regression catch: a normal entry (< CHUNK_SIZE) must produce
exactly one drawer with ``chunks == 1`` in the result. Existing
pre-#1539 behaviour preserved for the common path."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_write
r = tool_diary_write(
agent_name="TestAgent",
entry="A normal-length entry that fits comfortably under chunk_size.",
topic="general",
)
assert r["success"] is True
assert r["chunks"] == 1
_client2, col = _get_collection(palace_path)
del _client2
assert col.count() == 1
def test_diary_write_oversized_entry_chunked(self, monkeypatch, config, palace_path, kg):
"""Regression for #1539: an entry far above CHUNK_SIZE must be
sliced into bounded per-chunk drawers, each linked by a
``parent_entry_id`` metadata field. No single document stored
may exceed CHUNK_SIZE."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_write
# 5000 chars: well above CHUNK_SIZE=800. Expected chunks: ceil(5000/800) = 7.
oversized = "Z" * 5000
r = tool_diary_write(agent_name="TestAgent", entry=oversized, topic="general")
assert r["success"] is True
assert r["chunks"] > 1, f"oversized entry must produce >1 chunks; got {r['chunks']}"
assert "chunk_ids" in r and len(r["chunk_ids"]) == r["chunks"]
_client2, col = _get_collection(palace_path)
del _client2
stored = col.get()
assert all(len(d) <= 800 for d in stored["documents"]), (
f"no stored document may exceed CHUNK_SIZE=800; "
f"got max={max(len(d) for d in stored['documents'])}"
)
joined = "".join(stored["documents"])
assert joined == oversized, "joined chunks must equal original entry verbatim"
parent_ids = {m.get("parent_entry_id") for m in stored["metadatas"]}
assert len(parent_ids) == 1 and None not in parent_ids, (
f"all chunks must share one parent_entry_id; got {parent_ids}"
)
def test_diary_write_chunk_index_metadata(self, monkeypatch, config, palace_path, kg):
"""Regression for #1539: each oversized-entry chunk must carry a
``chunk_index`` metadata field that runs 0, 1, 2, ... in order."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_write
oversized = "Q" * 3500 # ~5 chunks at CHUNK_SIZE=800
r = tool_diary_write(agent_name="TestAgent", entry=oversized, topic="general")
assert r["success"] is True and r["chunks"] > 1
_client2, col = _get_collection(palace_path)
del _client2
stored = col.get()
indices = sorted(m["chunk_index"] for m in stored["metadatas"])
assert indices == list(range(len(indices))), (
f"chunk_index must be 0..N-1 contiguous; got {indices}"
)
# ── Cache Invalidation (inode/mtime) ──────────────────────────────────
class TestCacheInvalidation:
"""Tests for _get_collection inode/mtime cache invalidation logic."""
def test_mtime_change_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When mtime changes, the cached collection should be replaced."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
# Create a real collection so _get_collection succeeds
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
# Simulate an external write changing the mtime
old_mtime = mcp_server._palace_db_mtime
monkeypatch.setattr(mcp_server, "_palace_db_mtime", old_mtime - 10.0)
# _get_collection should detect the mtime drift and reconnect
col2 = mcp_server._get_collection()
assert col2 is not None
def test_inode_change_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When inode changes (file replaced), the cached collection should be replaced."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
# Simulate a rebuild that changes the inode
monkeypatch.setattr(mcp_server, "_palace_db_inode", 99999)
col2 = mcp_server._get_collection()
assert col2 is not None
@pytest.mark.skipif(
sys.platform == "win32",
reason="Windows holds chroma.sqlite3 open while the client is cached, blocking os.remove",
)
def test_missing_db_invalidates_cache(self, monkeypatch, config, palace_path, kg):
"""When chroma.sqlite3 disappears, a cached collection should be invalidated."""
_patch_mcp_server(monkeypatch, config, kg)
import os
from mempalace import mcp_server
_client, _col = _get_collection(palace_path, create=True)
del _client
# Prime the cache
col1 = mcp_server._get_collection()
assert col1 is not None
assert mcp_server._collection_cache is not None
# Delete the DB file to simulate a rebuild in progress
db_file = os.path.join(palace_path, "chroma.sqlite3")
if os.path.isfile(db_file):
os.remove(db_file)
make_client_calls = []
def fail_if_make_client_called(path):
make_client_calls.append(path)
raise AssertionError("_get_collection(create=False) should not open missing Chroma DB")
monkeypatch.setattr(mcp_server.ChromaBackend, "make_client", fail_if_make_client_called)
# Cache should be invalidated; _get_collection returns None
# because the backend can't open a missing DB without create=True
assert mcp_server._get_collection() is None
# The key assertion: the old cached collection was dropped
assert make_client_calls == []
assert mcp_server._collection_cache is None
assert mcp_server._palace_db_inode == 0
assert mcp_server._palace_db_mtime == 0.0
def test_reconnect_reports_failure_when_no_palace(self, monkeypatch, config, kg):
"""tool_reconnect should report failure when no collection is available."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
# Make _get_collection always return None
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: None)
result = mcp_server.tool_reconnect()
assert result["success"] is False
assert "No palace found" in result["message"]
assert result["drawers"] == 0
def test_reconnect_reports_success(self, monkeypatch, config, palace_path, kg):
"""tool_reconnect should report success with drawer count."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace import mcp_server
result = mcp_server.tool_reconnect()
assert result["success"] is True
assert "Reconnected" in result["message"]
assert isinstance(result["drawers"], int)
def test_reconnect_closes_shared_backend(self, monkeypatch, config, kg):
_patch_mcp_server(monkeypatch, config, kg)
from unittest.mock import MagicMock
from mempalace import mcp_server, palace
close_palace = MagicMock()
monkeypatch.setattr(palace._DEFAULT_BACKEND, "close_palace", close_palace)
class _FakeCol:
def count(self):
return 7
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: _FakeCol())
result = mcp_server.tool_reconnect()
assert result["success"] is True
closed_ref = close_palace.call_args.args[0]
assert closed_ref.local_path == config.palace_path
def test_reconnect_closes_selected_non_chroma_backend(
self, monkeypatch, config, palace_path, kg
):
_patch_mcp_server(monkeypatch, config, kg)
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
from mempalace import mcp_server, palace
closed = []
class _FakeBackend:
def close_palace(self, path):
closed.append(path)
class _FakeCol:
def count(self):
return 3
monkeypatch.setattr(palace, "get_backend_for_palace", lambda _path: _FakeBackend())
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: False)
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: _FakeCol())
result = mcp_server.tool_reconnect()
assert result["success"] is True
assert result["drawers"] == 3
assert len(closed) == 1
assert closed[0].local_path == palace_path
def test_reconnect_closes_previously_cached_backend(self, monkeypatch, config, palace_path, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import backends, mcp_server, palace
closed = []
class _SelectedBackend:
name = "sqlite_exact"
def close_palace(self, ref):
closed.append(("selected", ref.local_path))
class _CachedBackend:
name = "chroma"
def close_palace(self, ref):
closed.append(("cached", ref.local_path))
class _FakeCol:
def count(self):
return 3
monkeypatch.setattr(palace, "get_backend_for_palace", lambda _path: _SelectedBackend())
monkeypatch.setattr(backends, "get_backend", lambda _name: _CachedBackend())
monkeypatch.setattr(mcp_server, "_collection_cache_backend", "chroma")
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: False)
monkeypatch.setattr(mcp_server, "_get_collection", lambda create=False: _FakeCol())
result = mcp_server.tool_reconnect()
assert result["success"] is True
assert closed == [("selected", palace_path), ("cached", palace_path)]
def test_get_collection_create_true_avoids_get_or_create_on_reopen(
self, monkeypatch, config, palace_path, kg
):
"""Regression for the MCP-server half of #1262.
ChromaDB 1.5.x's Rust bindings SIGSEGV when
``client.get_or_create_collection`` is called with metadata that
differs from the collection's stored metadata. The Stop hook
path (``tool_diary_write`` -> ``_get_collection(create=True)``)
was reaching that codepath on every session-end; #1262 fixed
the equivalent crash class in ``ChromaBackend`` but left this
site untouched. ``_get_collection(create=True)`` must call
``client.get_collection`` first and only fall back to
``client.create_collection`` when the collection does not yet
exist on disk.
"""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
col1 = mcp_server._get_collection(create=True)
assert col1 is not None
client = mcp_server._client_cache
assert client is not None
# Patch at the class level — chromadb's mtime-change detection
# may rebuild the client between calls, so an instance-level
# spy would not survive.
client_cls = type(client)
calls: list[tuple] = []
def _spy(self, *args, **kwargs):
calls.append((args, kwargs))
raise AssertionError(
"get_or_create_collection must not be called on reopen "
"(SIGSEGV path on metadata mismatch)"
)
monkeypatch.setattr(client_cls, "get_or_create_collection", _spy)
mcp_server._collection_cache = None
col2 = mcp_server._get_collection(create=True)
assert col2 is not None
assert calls == [], f"get_or_create_collection was called: {calls}"
def test_get_collection_passes_embedding_function(self, monkeypatch, config, palace_path, kg):
"""Regression for #1299.
``mcp_server._get_collection`` must pass ``embedding_function=`` into
both ``client.get_collection`` and ``client.create_collection``,
mirroring ``ChromaBackend.get_collection``. Without it, ChromaDB 1.x
falls back to its built-in ``DefaultEmbeddingFunction`` (whose lazy
ONNX provider selection has SIGSEGV'd on python 3.14 + Apple Silicon),
and writers/readers can disagree with the miner about which EF is
bound to the collection. The miner / Stop hook ingest path routes
through ``ChromaBackend.get_collection`` which does this correctly;
the MCP server must match.
"""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
client = mcp_server._get_client()
client_cls = type(client)
captured: dict[str, list[dict]] = {"get": [], "create": []}
real_get = client_cls.get_collection
real_create = client_cls.create_collection
def _spy_get(self, name, **kwargs):
captured["get"].append(dict(kwargs))
return real_get(self, name, **kwargs)
def _spy_create(self, name, **kwargs):
captured["create"].append(dict(kwargs))
return real_create(self, name, **kwargs)
monkeypatch.setattr(client_cls, "get_collection", _spy_get)
monkeypatch.setattr(client_cls, "create_collection", _spy_create)
mcp_server._collection_cache = None
col = mcp_server._get_collection(create=True)
assert col is not None
all_calls = captured["get"] + captured["create"]
assert all_calls, "expected get_collection or create_collection to be called"
for kwargs in all_calls:
assert "embedding_function" in kwargs, (
f"missing embedding_function= in chromadb call: {kwargs}"
)
assert kwargs["embedding_function"] is not None
# Same expectation on the create=False (cache-miss) reopen path.
mcp_server._collection_cache = None
captured["get"].clear()
captured["create"].clear()
col2 = mcp_server._get_collection()
assert col2 is not None
assert captured["get"], "expected get_collection on cache-miss reopen"
for kwargs in captured["get"]:
assert "embedding_function" in kwargs
assert kwargs["embedding_function"] is not None
def test_get_collection_retries_once_on_exception(self, monkeypatch, config, palace_path, kg):
"""Regression: a transient failure inside _get_collection must trigger
one retry after clearing the client/collection caches, not silently
return None.
Before this fix, a stale chromadb handle (e.g. the rust bindings
invalidating after an out-of-band write) would raise inside the
single ``try`` block, get swallowed by ``except Exception: return
None``, and every subsequent tool call would hit the same poisoned
cache returning None. The retry forces ``_get_client()`` to rebuild
the client (which re-runs ``quarantine_stale_hnsw`` per #1322), so
the second attempt heals the common stale-handle case.
"""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace import mcp_server
# Force a cold cache so the first call goes through the open path.
mcp_server._client_cache = None
mcp_server._collection_cache = None
real_get_client = mcp_server._get_client
attempts = {"count": 0}
def flaky_get_client():
attempts["count"] += 1
if attempts["count"] == 1:
raise RuntimeError("simulated transient chromadb failure")
return real_get_client()
monkeypatch.setattr(mcp_server, "_get_client", flaky_get_client)
col = mcp_server._get_collection()
# Both attempts ran and the second succeeded.
assert attempts["count"] == 2
assert col is not None
def test_get_collection_returns_none_after_two_failures(
self, monkeypatch, config, palace_path, kg
):
"""If both attempts fail, return None (matches the prior contract for
permanent failures — only the transient case is now self-healing)."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace import mcp_server
mcp_server._client_cache = None
mcp_server._collection_cache = None
attempts = {"count": 0}
def always_fails():
attempts["count"] += 1
raise RuntimeError("permanent chromadb failure")
monkeypatch.setattr(mcp_server, "_get_client", always_fails)
col = mcp_server._get_collection()
assert attempts["count"] == 2
assert col is None
class TestImportKillSwitchSafety:
"""Importing mcp_server must not recreate ~/.mempalace (#1676).
The module-level WAL setup used to ``mkdir(parents=True)`` at import,
recreating ``~/.mempalace`` even after the user removed it as the
documented kill-switch gesture (``_palace_root_exists()``, #1305),
silently re-arming the autosave/mining hooks. WAL creation is now
deferred to the first actual write.
"""
def test_import_does_not_recreate_palace_root(self, tmp_path):
"""import mempalace.mcp_server must not create ~/.mempalace.
Runs in a fresh subprocess with HOME pointed at tmp_path so the
assertion targets a clean filesystem, independent of conftest's
session-level HOME patch.
"""
palace_root = tmp_path / ".mempalace"
env = {k: v for k, v in os.environ.items() if not k.startswith("MEMPAL")}
env["HOME"] = str(tmp_path)
env["USERPROFILE"] = str(tmp_path)
result = subprocess.run(
[sys.executable, "-c", "import mempalace.mcp_server"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"import failed: {result.stderr}"
assert not palace_root.exists(), (
f"importing mcp_server recreated {palace_root} as a side effect, "
"defeating the _palace_root_exists() kill-switch (#1676)"
)
def test_wal_log_creates_dir_lazily_on_first_write(self, tmp_path, monkeypatch):
"""_wal_log creates its directory on first use.
Proves the deferred setup still works (defers WAL creation to write
time, does not disable it) and preserves the WAL permission bits.
"""
from mempalace import wal
wal_file = tmp_path / "fresh" / "wal" / "write_log.jsonl"
assert not wal_file.parent.exists()
monkeypatch.setattr(wal, "_WAL_FILE", wal_file)
wal._wal_log("test_op", {"safe": "ok"})
assert wal_file.exists(), "lazy WAL init did not create the log on first write"
entry = json.loads(wal_file.read_text().strip())
assert entry["operation"] == "test_op"
assert entry["params"]["safe"] == "ok"
# Permission bits the refactor must preserve (POSIX only; Windows
# ignores chmod and the code swallows NotImplementedError).
if sys.platform != "win32":
assert wal_file.stat().st_mode & 0o777 == 0o600
assert wal_file.parent.stat().st_mode & 0o777 == 0o700
class TestKGLazyCache:
"""Lazy per-path KnowledgeGraph cache (issue #1136)."""
def test_lazy_init_no_import_side_effect(self, tmp_path):
"""Importing mcp_server must not create knowledge_graph.sqlite3.
Runs in a fresh subprocess with HOME pointed at tmp_path so the
assertion targets a clean filesystem, independent of conftest's
session-level HOME patch.
"""
import subprocess
import sys
kg_file = tmp_path / ".mempalace" / "knowledge_graph.sqlite3"
env = {k: v for k, v in os.environ.items() if not k.startswith("MEMPAL")}
env["HOME"] = str(tmp_path)
env["USERPROFILE"] = str(tmp_path)
result = subprocess.run(
[sys.executable, "-c", "import mempalace.mcp_server"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"import failed: {result.stderr}"
assert not kg_file.exists(), f"import created sqlite file at {kg_file} as a side effect"
def test_get_kg_returns_same_instance(self, tmp_path, monkeypatch):
"""Two calls with the same resolved path return the same KG."""
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_kg_by_path", {})
monkeypatch.setattr(mcp_server, "_palace_flag_given", True)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_path))
kg1 = mcp_server._get_kg()
kg2 = mcp_server._get_kg()
assert kg1 is kg2
assert len(mcp_server._kg_by_path) == 1
def test_get_kg_different_paths_different_instances(self, tmp_path, monkeypatch):
"""Different palace paths map to different KG instances."""
from mempalace import mcp_server
tmp_a = tmp_path / "a"
tmp_b = tmp_path / "b"
tmp_a.mkdir()
tmp_b.mkdir()
monkeypatch.setattr(mcp_server, "_kg_by_path", {})
monkeypatch.setattr(mcp_server, "_palace_flag_given", True)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_a))
kg_a = mcp_server._get_kg()
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_b))
kg_b = mcp_server._get_kg()
assert kg_a is not kg_b
assert len(mcp_server._kg_by_path) == 2
def test_multi_tenant_env_switch(self, tmp_path, monkeypatch):
"""The issue #1136 acceptance scenario.
Rotating MEMPALACE_PALACE_PATH between MCP tool calls must route
each call to the correct tenant's KG sqlite file.
"""
from mempalace import mcp_server
tmp_a = tmp_path / "tenant_a"
tmp_b = tmp_path / "tenant_b"
tmp_a.mkdir()
tmp_b.mkdir()
monkeypatch.setattr(mcp_server, "_kg_by_path", {})
monkeypatch.setattr(mcp_server, "_palace_flag_given", True)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_a))
add_result = mcp_server.tool_kg_add(
subject="alice_secret",
predicate="owns",
object="repo_a",
)
assert add_result.get("success") is True, add_result
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_b))
query_b = mcp_server.tool_kg_query(entity="alice_secret")
assert query_b.get("count", 0) == 0, f"tenant B leaked tenant A's fact: {query_b}"
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_a))
query_a = mcp_server.tool_kg_query(entity="alice_secret")
assert query_a.get("count", 0) >= 1, f"tenant A lost its own fact: {query_a}"
# ── Structured error codes + MineAlreadyRunning (#1552) ─────────────────
class TestStructuredErrors:
"""Verify that _internal_tool_error and MineAlreadyRunning return
machine-readable structured data (#1552)."""
def test_internal_tool_error_without_exc_has_no_data_field(self):
"""Backward-compat: callers that omit exc still get a valid error dict."""
from mempalace.mcp_server import _internal_tool_error
try:
raise ValueError("test error")
except ValueError:
resp = _internal_tool_error("req-1", "mempalace_search")
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == "req-1"
err = resp["error"]
assert err["code"] == -32000
assert err["message"] == "Internal tool error"
assert "data" not in err
def test_internal_tool_error_with_exc_includes_structured_data(self):
"""When exc is supplied, the error body must include data.error_class
and data.message so callers can distinguish error types (#1552)."""
from mempalace.mcp_server import _internal_tool_error
exc = RuntimeError("chromadb cold init wedge")
try:
raise exc
except RuntimeError:
resp = _internal_tool_error("req-2", "mempalace_add_drawer", exc)
err = resp["error"]
assert err["code"] == -32000
assert "data" in err
assert err["data"]["error_class"] == "RuntimeError"
assert "chromadb cold init wedge" in err["data"]["message"]
def test_internal_tool_error_exception_dispatch_passes_exc(self, monkeypatch):
"""handle_request's Exception branch must pass exc to _internal_tool_error."""
from mempalace import mcp_server
captured = {}
def fake_handler(**kwargs):
raise OSError("fake disk error")
fake_tool_entry = {
"handler": fake_handler,
"input_schema": {"type": "object", "properties": {}},
}
monkeypatch.setattr(
mcp_server,
"TOOLS",
{"mempalace_fake": fake_tool_entry},
)
original = mcp_server._internal_tool_error
def spy_error(req_id, tool_name, exc=None):
captured["exc"] = exc
return original(req_id, tool_name, exc)
monkeypatch.setattr(mcp_server, "_internal_tool_error", spy_error)
req = {
"jsonrpc": "2.0",
"id": "r1",
"method": "tools/call",
"params": {"name": "mempalace_fake", "arguments": {}},
}
resp = mcp_server.handle_request(req)
assert resp["error"]["code"] == -32000
assert isinstance(captured.get("exc"), OSError)
assert "data" in resp["error"]
assert resp["error"]["data"]["error_class"] == "OSError"
def test_tool_sync_mine_already_running_returns_error_class(self, monkeypatch, tmp_path):
"""tool_sync MineAlreadyRunning path returns error_class: LockHeldByOtherProcess."""
from mempalace import mcp_server
from mempalace.palace import MineAlreadyRunning
cfg = MagicMock()
cfg.palace_path = str(tmp_path / "palace")
monkeypatch.setattr(mcp_server, "_config", cfg)
monkeypatch.setattr(mcp_server, "_get_kg", lambda *a, **kw: MagicMock())
def _raise_locked(*args, **kwargs):
raise MineAlreadyRunning("pid=12345")
import mempalace.sync as sync_mod
monkeypatch.setattr(sync_mod, "sync_palace", _raise_locked, raising=False)
result = mcp_server.tool_sync()
assert result["success"] is False
assert "another mine is in progress" in result["error"]
assert result.get("error_class") == "LockHeldByOtherProcess"
def test_mcp_idle_timeout_invalid_env_disables_watchdog(self, monkeypatch):
"""Invalid MEMPALACE_MCP_IDLE_HOURS disables idle auto-exit."""
from mempalace import mcp_server
monkeypatch.setenv("MEMPALACE_MCP_IDLE_HOURS", "not-a-float")
assert mcp_server._mcp_idle_timeout_secs() == 0.0
def test_cache_thread_safe(self, tmp_path, monkeypatch):
"""Concurrent _get_kg() for the same path yields one instance."""
import concurrent.futures
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_kg_by_path", {})
monkeypatch.setattr(mcp_server, "_palace_flag_given", True)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_path))
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(lambda _: mcp_server._get_kg(), range(16)))
ids = {id(kg) for kg in results}
assert len(ids) == 1, f"expected 1 unique instance, got {len(ids)}"
assert len(mcp_server._kg_by_path) == 1
def test_tool_reconnect_drains_kg_cache(self, monkeypatch):
"""``tool_reconnect`` must close cached KG instances and clear the dict.
Without this, an external replacement of ``knowledge_graph.sqlite3``
leaves the server pinned to a stale ``sqlite3.Connection``.
"""
from mempalace import mcp_server
class _FakeKG:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
fake_a = _FakeKG()
fake_b = _FakeKG()
monkeypatch.setattr(mcp_server, "_kg_by_path", {"/a": fake_a, "/b": fake_b})
# Bypass real ChromaDB so the test isolates KG-cache behaviour.
monkeypatch.setattr(mcp_server, "_get_collection", lambda: None)
mcp_server.tool_reconnect()
assert fake_a.closed is True
assert fake_b.closed is True
assert mcp_server._kg_by_path == {}
def test_tool_reconnect_swallows_kg_close_errors(self, monkeypatch):
"""A failing ``close()`` on one cached KG must not block cache clearing."""
from mempalace import mcp_server
class _BoomKG:
def close(self):
raise RuntimeError("boom")
monkeypatch.setattr(mcp_server, "_kg_by_path", {"/a": _BoomKG()})
monkeypatch.setattr(mcp_server, "_get_collection", lambda: None)
mcp_server.tool_reconnect()
assert mcp_server._kg_by_path == {}
def test_tool_reconnect_rearms_quarantine_gate(self, monkeypatch):
"""``tool_reconnect`` must clear the per-process quarantine gate so
HNSW safety checks re-run on the next open (#1573)."""
from mempalace import mcp_server
from mempalace.backends.chroma import ChromaBackend
palace_path = "/test/palace/quarantine_rearm"
gate = {palace_path}
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", gate)
monkeypatch.setattr(mcp_server, "_config", type("C", (), {"palace_path": palace_path})())
monkeypatch.setattr(mcp_server, "_get_collection", lambda: None)
mcp_server.tool_reconnect()
assert palace_path not in gate, (
"tool_reconnect should clear quarantine gate for the palace path"
)
def test_get_client_rearms_quarantine_on_reconnect(self, monkeypatch, config, palace_path, kg):
"""``_get_client`` must clear the quarantine gate before calling
``make_client`` so HNSW safety checks re-run on reconnect (#1573)."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
from mempalace.backends.chroma import ChromaBackend
_client, _col = _get_collection(palace_path, create=True)
del _client
mcp_server._get_collection()
assert config.palace_path in ChromaBackend._quarantined_paths
old_mtime = mcp_server._palace_db_mtime
monkeypatch.setattr(mcp_server, "_palace_db_mtime", old_mtime - 10.0)
quarantine_calls: list[str] = []
original_prepare = ChromaBackend._prepare_palace_for_open
@staticmethod
def spy_prepare(path):
quarantine_calls.append(path)
original_prepare(path)
monkeypatch.setattr(ChromaBackend, "_prepare_palace_for_open", spy_prepare)
mcp_server._get_client()
assert len(quarantine_calls) == 1, (
"_get_client should call _prepare_palace_for_open on reconnect"
)
def test_call_kg_retries_after_concurrent_close(self, monkeypatch):
"""A KG closed mid-handler must trigger a one-shot retry with a fresh
instance — not surface a -32000 to the MCP client."""
import sqlite3 as _sqlite3
from mempalace import mcp_server
path = "/fake/palace/knowledge_graph.sqlite3"
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: path)
class _ClosedKG:
def query_entity(self, entity, **kwargs):
raise _sqlite3.ProgrammingError("Cannot operate on a closed database")
class _FreshKG:
def query_entity(self, entity, **kwargs):
return [{"entity": entity}]
cache = {mcp_server._canonicalize_kg_path(path): _ClosedKG()}
monkeypatch.setattr(mcp_server, "_kg_by_path", cache)
# Second _get_kg() call (after the cache eviction) constructs a new
# KG. Patch the constructor so we don't open a real sqlite file.
monkeypatch.setattr(mcp_server, "KnowledgeGraph", lambda **_: _FreshKG())
result = mcp_server._call_kg(lambda kg: kg.query_entity("Alice"))
assert result == [{"entity": "Alice"}]
# The closed instance must be evicted; the fresh one must be cached.
assert isinstance(cache[mcp_server._canonicalize_kg_path(path)], _FreshKG)
def test_call_kg_does_not_retry_on_other_errors(self, monkeypatch):
"""Non-ProgrammingError exceptions must propagate without retry —
we don't want the retry guard masking real bugs."""
from mempalace import mcp_server
path = "/fake/palace/knowledge_graph.sqlite3"
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: path)
calls = {"count": 0}
class _FailingKG:
def query_entity(self, entity, **kwargs):
calls["count"] += 1
raise ValueError("bad input")
monkeypatch.setattr(
mcp_server, "_kg_by_path", {mcp_server._canonicalize_kg_path(path): _FailingKG()}
)
monkeypatch.setattr(mcp_server, "KnowledgeGraph", lambda **_: _FailingKG())
with pytest.raises(ValueError, match="bad input"):
mcp_server._call_kg(lambda kg: kg.query_entity("Alice"))
assert calls["count"] == 1, "non-ProgrammingError must not trigger retry"
def test_call_kg_gives_up_after_one_retry(self, monkeypatch):
"""If the second attempt also hits a closed DB, give up rather than
loop forever — a sustained close-stream is a different bug."""
import sqlite3 as _sqlite3
from mempalace import mcp_server
path = "/fake/palace/knowledge_graph.sqlite3"
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: path)
calls = {"count": 0}
class _AlwaysClosedKG:
def query_entity(self, entity, **kwargs):
calls["count"] += 1
raise _sqlite3.ProgrammingError("closed again")
cache = {}
monkeypatch.setattr(mcp_server, "_kg_by_path", cache)
monkeypatch.setattr(mcp_server, "KnowledgeGraph", lambda **_: _AlwaysClosedKG())
with pytest.raises(_sqlite3.ProgrammingError):
mcp_server._call_kg(lambda kg: kg.query_entity("Alice"))
assert calls["count"] == 2, "expected exactly one retry beyond the initial attempt"
def test_call_kg_passes_captured_path_through_resolve_drift(self, monkeypatch):
"""``_call_kg`` must thread its captured canonical path through
``_get_kg`` so insertion and eviction agree on the cache key even
when FS or env state would otherwise drift between attempts. The
end-to-end invariant: after the retry, the closed handle that was
cached under the captured path is gone (evicted) and the cache no
longer holds it under the stale key.
"""
import sqlite3 as _sqlite3
from mempalace import mcp_server
class _ClosedKG:
def query_entity(self, entity, **kwargs):
raise _sqlite3.ProgrammingError("Cannot operate on a closed database")
class _FreshKG:
def query_entity(self, entity, **kwargs):
return [{"entity": entity}]
# _resolve_kg_path returns shifting values (env rotation between
# attempts). _canonicalize_kg_path is identity so paths flow
# through verbatim.
resolved_seq = iter(["/path/v1", "/path/v2", "/path/v3"])
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: next(resolved_seq))
monkeypatch.setattr(mcp_server, "_canonicalize_kg_path", lambda p: p)
closed = _ClosedKG()
cache = {"/path/v1": closed}
monkeypatch.setattr(mcp_server, "_kg_by_path", cache)
get_kg_args: list = []
def spy_get_kg(canonical_path=None):
get_kg_args.append(canonical_path)
return cache.get(canonical_path) if canonical_path in cache else _FreshKG()
monkeypatch.setattr(mcp_server, "_get_kg", spy_get_kg)
result = mcp_server._call_kg(lambda kg: kg.query_entity("Alice"))
assert result == [{"entity": "Alice"}]
# Both _get_kg calls received the captured path "/path/v1" rather
# than the drifted "/path/v2". Without pass-through, the second
# call would have used "/path/v2" and the closed handle at
# "/path/v1" would never have been evicted.
assert get_kg_args == ["/path/v1", "/path/v1"], (
f"expected both _get_kg calls to receive captured '/path/v1', "
f"got {get_kg_args} -- captured-path pass-through broken"
)
# Eviction landed under the captured key: the closed handle is
# gone from the cache. With drift the closed handle would still
# be at "/path/v1" because eviction would have probed "/path/v2".
assert "/path/v1" not in cache, (
f"closed handle leaked under captured key after retry; "
f"cache state: {[(k, type(v).__name__) for k, v in cache.items()]}"
)
def test_call_kg_oserror_at_top_propagates_unmasked(self, monkeypatch):
"""``OSError`` from ``_canonicalize_kg_path`` at the top of
``_call_kg`` (e.g. transient Windows realpath hiccup on a stale
junction) must propagate unchanged. The fix-rationale invariant:
capturing the canonical path before the retry loop means an FS
error surfaces cleanly to the dispatcher's exception envelope
instead of getting raised inside the ``except`` branch where it
would mask a ``sqlite3.ProgrammingError``.
"""
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: "/fake/path")
monkeypatch.setattr(
mcp_server,
"_canonicalize_kg_path",
lambda p: (_ for _ in ()).throw(OSError("simulated realpath failure")),
)
op_calls = {"n": 0}
def op(kg):
op_calls["n"] += 1
return None
with pytest.raises(OSError, match="simulated realpath failure"):
mcp_server._call_kg(op)
assert op_calls["n"] == 0, "op must not run if canonicalize fails at top"
def test_canonicalize_kg_path_collapses_symlink_alias(self, tmp_path):
"""A symlink layer over the palace directory must collapse to one
cache key — otherwise two tenants pointing at /srv/A and
/srv/link-to-A open duplicate sqlite3.Connections over the same
file."""
if sys.platform == "win32":
pytest.skip("symlink creation requires admin privileges on Windows runners")
from mempalace import mcp_server
target = tmp_path / "real"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target)
real_db = str(target / "knowledge_graph.sqlite3")
link_db = str(link / "knowledge_graph.sqlite3")
assert mcp_server._canonicalize_kg_path(real_db) == mcp_server._canonicalize_kg_path(
link_db
)
def test_canonicalize_kg_path_routes_through_normcase(self, monkeypatch):
"""``_canonicalize_kg_path`` must apply ``os.path.normcase`` so the
cache key collapses Windows drive-letter casing
(``C:\\palace`` vs ``c:\\palace``). On POSIX runners normcase is a
no-op, so we patch both ``realpath`` and ``normcase`` with sentinel
wrappers and assert the helper composes them as
``normcase(realpath(p))`` -- swapping the order would leave Windows
symlinks under the original case, defeating the dedup.
"""
from mempalace import mcp_server
def fake_realpath(p: str) -> str:
return f"<RP:{p}>"
def fake_normcase(p: str) -> str:
return f"<NC:{p}>"
monkeypatch.setattr(os.path, "realpath", fake_realpath)
monkeypatch.setattr(os.path, "normcase", fake_normcase)
result = mcp_server._canonicalize_kg_path("/some/Path/KG.sqlite3")
assert result == "<NC:<RP:/some/Path/KG.sqlite3>>", (
f"expected normcase(realpath(p)) composition, got {result!r}"
)
def test_get_kg_dedupes_symlink_alias_end_to_end(self, tmp_path, monkeypatch):
"""End-to-end: two ``_get_kg()`` calls via different symlink layers
return the same cached instance and construct only one
``KnowledgeGraph``."""
if sys.platform == "win32":
pytest.skip("symlink creation requires admin privileges on Windows runners")
from mempalace import mcp_server
target = tmp_path / "real"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target)
real_db = str(target / "knowledge_graph.sqlite3")
link_db = str(link / "knowledge_graph.sqlite3")
constructed: list = []
class _StubKG:
def __init__(self, db_path=None):
constructed.append(db_path)
monkeypatch.setattr(mcp_server, "_kg_by_path", {})
monkeypatch.setattr(mcp_server, "KnowledgeGraph", _StubKG)
paths = iter([real_db, link_db])
monkeypatch.setattr(mcp_server, "_resolve_kg_path", lambda: next(paths))
kg1 = mcp_server._get_kg()
kg2 = mcp_server._get_kg()
assert kg1 is kg2, "symlink alias must hit the cached KG, not construct a duplicate"
assert len(constructed) == 1, f"expected 1 KG construction, got {len(constructed)}"
assert len(mcp_server._kg_by_path) == 1
# ── Param-shape diagnostics on tools/call dispatch (#1351) ──────────────
class TestParamShapeDiagnostics:
"""Dispatch-level TypeError on tools/call should surface as JSON-RPC
-32602 (Invalid params) with the offending parameter named, instead of
the opaque -32000 Internal tool error. Handler-internal TypeError and
non-TypeError exceptions stay generic -32000 (no internals leak).
"""
def test_missing_required_returns_32602_with_param_name(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "tools/call",
"id": 1,
"params": {
"name": "mempalace_diary_write",
"arguments": {"agent_name": "test"},
},
}
)
assert resp["error"]["code"] == -32602
assert "'entry'" in resp["error"]["message"]
assert "mempalace_diary_write" in resp["error"]["message"]
def test_handler_internal_typeerror_stays_generic_32000(self, monkeypatch):
from mempalace import mcp_server
def boom(**_kw):
raise TypeError("unsupported operand type(s) for +: 'int' and 'str'")
monkeypatch.setitem(mcp_server.TOOLS["mempalace_status"], "handler", boom)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 2,
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert resp["error"]["code"] == -32000
assert resp["error"]["message"] == "Internal tool error"
assert "unsupported operand" not in resp["error"]["message"]
def test_chromadb_exception_stays_generic_32000(self, monkeypatch):
from mempalace import mcp_server
def boom(**_kw):
raise RuntimeError("db schema mismatch at /private/path/chroma.sqlite3")
monkeypatch.setitem(mcp_server.TOOLS["mempalace_status"], "handler", boom)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 3,
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert resp["error"]["code"] == -32000
assert resp["error"]["message"] == "Internal tool error"
assert "db schema" not in resp["error"]["message"]
assert "/private/path" not in resp["error"]["message"]
def test_two_missing_required_lists_both_names(self):
"""For 2+ missing args Python emits 'a' and 'b'; the response should
list both quoted names, not return a syntactically broken string.
"""
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "tools/call",
"id": 4,
"params": {"name": "mempalace_diary_write", "arguments": {}},
}
)
assert resp["error"]["code"] == -32602
message = resp["error"]["message"]
assert "parameters" in message
assert "'agent_name'" in message
assert "'entry'" in message
assert " and " not in message.split("for tool")[0]
def test_diary_write_content_aliases_entry(self, monkeypatch):
"""A content-only diary_write call is remapped to 'entry' before
dispatch (#1245 alias), so it satisfies the required param and the
alias key is consumed rather than passed through to the handler.
"""
from mempalace import mcp_server
captured = {}
def capture(**kwargs):
captured.update(kwargs)
return {"success": True}
monkeypatch.setitem(mcp_server.TOOLS["mempalace_diary_write"], "handler", capture)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 5,
"params": {
"name": "mempalace_diary_write",
"arguments": {"agent_name": "test", "content": "hello world"},
},
}
)
assert "error" not in resp
assert captured.get("entry") == "hello world"
assert "content" not in captured
def test_diary_write_entry_wins_over_content(self, monkeypatch):
"""When both 'entry' and the 'content' alias are supplied, 'entry' wins
and the alias is dropped.
"""
from mempalace import mcp_server
captured = {}
def capture(**kwargs):
captured.update(kwargs)
return {"success": True}
monkeypatch.setitem(mcp_server.TOOLS["mempalace_diary_write"], "handler", capture)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 6,
"params": {
"name": "mempalace_diary_write",
"arguments": {"agent_name": "t", "entry": "real", "content": "alias"},
},
}
)
assert "error" not in resp
assert captured.get("entry") == "real"
assert "content" not in captured
def test_diary_write_explicit_empty_entry_not_overridden_by_content(self, monkeypatch):
"""An explicitly supplied (even falsy "") 'entry' wins over 'content' —
the alias only fills in when 'entry' is absent or null, not merely falsy.
"""
from mempalace import mcp_server
captured = {}
def capture(**kwargs):
captured.update(kwargs)
return {"success": True}
monkeypatch.setitem(mcp_server.TOOLS["mempalace_diary_write"], "handler", capture)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 7,
"params": {
"name": "mempalace_diary_write",
"arguments": {"agent_name": "t", "entry": "", "content": "alias"},
},
}
)
assert "error" not in resp
assert captured.get("entry") == ""
assert "content" not in captured
def test_handler_internal_signature_shape_stays_generic(self, monkeypatch):
"""A TypeError whose function name does not match the dispatched
handler — e.g. raised by a helper called inside the handler body —
must fall through to generic -32000, otherwise we'd leak internal
helper/parameter names as if they were public tool parameters.
"""
from mempalace import mcp_server
def calling_handler(**_kw):
def helper(req):
return req
helper()
monkeypatch.setitem(mcp_server.TOOLS["mempalace_status"], "handler", calling_handler)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 5,
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert resp["error"]["code"] == -32000
assert resp["error"]["message"] == "Internal tool error"
assert "'req'" not in resp["error"]["message"]
assert "helper" not in resp["error"]["message"]
def test_unexpected_kw_typeerror_inside_handler_stays_generic(self, monkeypatch):
"""The 'got an unexpected keyword argument' shape is unreachable from
real dispatch (schema-filter on line 2236 drops unknown kwargs for
normal handlers; **kwargs handlers per #684 accept anything). If a
handler raises that shape manually, the qualname mismatch must keep
it on the generic -32000 path so internal helper names cannot leak.
"""
from mempalace import mcp_server
def boom(**_kw):
raise TypeError("some_helper() got an unexpected keyword argument 'foo'")
monkeypatch.setitem(mcp_server.TOOLS["mempalace_status"], "handler", boom)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 6,
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert resp["error"]["code"] == -32000
assert resp["error"]["message"] == "Internal tool error"
assert "'foo'" not in resp["error"]["message"]
assert "some_helper" not in resp["error"]["message"]
class TestUnknownParamName:
"""A kwarg not in the tool schema (wrong parameter *name*, e.g. text=
instead of content=) should surface as JSON-RPC -32602 naming the
offending kwarg, instead of being silently dropped and resurfacing
indirectly as a later "Missing required 'X'". Symmetric with the
missing-required path in TestParamShapeDiagnostics. The internal
wait_for_previous transport kwarg must never be flagged, and
**kwargs pass-through handlers must keep accepting unknown kwargs.
"""
def test_unknown_param_returns_32602_naming_the_wrong_kwarg(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "tools/call",
"id": 7,
"params": {
"name": "mempalace_add_drawer",
"arguments": {"wing": "w", "room": "r", "text": "hello"},
},
}
)
assert resp["error"]["code"] == -32602
message = resp["error"]["message"]
assert "'text'" in message
assert "Unknown parameter" in message
assert "mempalace_add_drawer" in message
# Names the actual wrong kwarg, not the indirect missing-required symptom.
assert "Missing required" not in message
def test_two_unknown_params_list_both_names(self):
from mempalace.mcp_server import handle_request
resp = handle_request(
{
"method": "tools/call",
"id": 8,
"params": {
"name": "mempalace_add_drawer",
"arguments": {"wing": "w", "room": "r", "text": "a", "bogus": "b"},
},
}
)
assert resp["error"]["code"] == -32602
message = resp["error"]["message"]
assert "parameters" in message
assert "'text'" in message
assert "'bogus'" in message
def test_wait_for_previous_not_flagged_as_unknown(self, monkeypatch):
"""wait_for_previous is an internal transport kwarg in no tool schema;
it is popped before dispatch and must not trip the unknown-param check
for a normal (non-**kwargs) handler.
"""
from mempalace import mcp_server
def stub(agent_name, entry, topic="general"):
return {"ok": True, "agent": agent_name}
monkeypatch.setitem(mcp_server.TOOLS["mempalace_diary_write"], "handler", stub)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 9,
"params": {
"name": "mempalace_diary_write",
"arguments": {
"agent_name": "x",
"entry": "y",
"wait_for_previous": True,
},
},
}
)
assert "error" not in resp
assert "result" in resp
def test_kwargs_passthrough_handler_keeps_accepting_unknown(self, monkeypatch):
"""Handlers that explicitly accept **kwargs (per #684) bypass the
schema filter entirely, so an unknown kwarg must still pass through
rather than being rejected as -32602.
"""
from mempalace import mcp_server
def passthrough(**kwargs):
return {"ok": True, "got": sorted(kwargs)}
monkeypatch.setitem(mcp_server.TOOLS["mempalace_status"], "handler", passthrough)
resp = mcp_server.handle_request(
{
"method": "tools/call",
"id": 10,
"params": {"name": "mempalace_status", "arguments": {"bogus": 1}},
}
)
assert "error" not in resp
assert "result" in resp
def test_peer_writer_guard_refuses_mutating_tool_before_handler(monkeypatch):
from mempalace import mcp_server
called = {"value": False}
def handler(**kwargs):
called["value"] = True
return {"ok": True}
monkeypatch.setitem(
mcp_server.TOOLS,
"mempalace_add_drawer",
{
"description": "test write tool",
"input_schema": {
"type": "object",
"properties": {
"wing": {"type": "string"},
"room": {"type": "string"},
"content": {"type": "string"},
},
},
"handler": handler,
},
)
monkeypatch.setattr(
mcp_server,
"_acquire_mcp_writer_lock",
lambda: (False, "busy writer"),
)
response = mcp_server.handle_request(
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "mempalace_add_drawer",
"arguments": {
"wing": "wing_test",
"room": "room_test",
"content": "hello",
},
},
}
)
assert called["value"] is False
assert response["error"]["code"] == -32001
assert "read-only" in response["error"]["message"]
assert response["error"]["data"]["tool"] == "mempalace_add_drawer"
def test_peer_writer_guard_does_not_gate_read_tool(monkeypatch):
from mempalace import mcp_server
def forbidden_lock():
raise AssertionError("read tools should not acquire the peer-writer lock")
monkeypatch.setitem(
mcp_server.TOOLS,
"mempalace_status",
{
"description": "test read tool",
"input_schema": {"type": "object", "properties": {}},
"handler": lambda: {"ok": True},
},
)
monkeypatch.setattr(mcp_server, "_acquire_mcp_writer_lock", forbidden_lock)
response = mcp_server.handle_request(
{
"jsonrpc": "2.0",
"id": 8,
"method": "tools/call",
"params": {"name": "mempalace_status", "arguments": {}},
}
)
assert '"ok": true' in response["result"]["content"][0]["text"]
def test_status_tool_does_not_acquire_peer_writer_lock(monkeypatch):
from mempalace import mcp_server
def forbidden_lock():
raise AssertionError("status should not acquire the peer-writer lock")
monkeypatch.setattr(mcp_server, "_ensure_sqlite_integrity_status", lambda: None)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", None)
monkeypatch.setattr(mcp_server, "_backend_db_exists", lambda: True)
monkeypatch.setattr(mcp_server, "_refresh_vector_disabled_flag", lambda: None)
monkeypatch.setattr(mcp_server, "_vector_disabled", True)
monkeypatch.setattr(
mcp_server,
"_tool_status_via_sqlite",
lambda: {"total_drawers": 0, "wings": {}, "rooms": {}},
)
monkeypatch.setattr(mcp_server, "_acquire_mcp_writer_lock", forbidden_lock)
assert mcp_server.tool_status()["total_drawers"] == 0
def test_peer_writer_lock_setup_failure_is_cached(monkeypatch):
from mempalace import mcp_server, palace
calls = {"count": 0}
def broken_mine_palace_lock(palace_path):
calls["count"] += 1
raise RuntimeError(f"permission denied for {palace_path}")
monkeypatch.delenv(mcp_server._MCP_ALLOW_PEER_WRITER_ENV, raising=False)
monkeypatch.setattr(palace, "mine_palace_lock", broken_mine_palace_lock)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_CM", None)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_READ_ONLY", False)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_FAILED", False)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_ERROR", "")
ok_first, reason_first = mcp_server._acquire_mcp_writer_lock()
ok_second, reason_second = mcp_server._acquire_mcp_writer_lock()
assert ok_first is True
assert ok_second is True
assert calls["count"] == 1
assert mcp_server._MCP_WRITER_LOCK_FAILED is True
assert "continuing without peer-writer protection" in reason_first
assert reason_second == reason_first
def test_peer_writer_readonly_self_heals_after_peer_exits(monkeypatch):
"""A server that came up read-only must retry the flock and promote itself
to writer once the peer holding the lease exits — no restart required."""
from mempalace import mcp_server, palace
class _DummyLock:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
calls = {"count": 0}
def flaky_mine_palace_lock(palace_path):
calls["count"] += 1
if calls["count"] == 1:
# First attempt: a live peer still holds the lease.
raise palace.MineAlreadyRunning(f"palace {palace_path} is held by pid=999")
# Second attempt: peer has exited, flock is free.
return _DummyLock()
monkeypatch.delenv(mcp_server._MCP_ALLOW_PEER_WRITER_ENV, raising=False)
monkeypatch.setattr(palace, "mine_palace_lock", flaky_mine_palace_lock)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_CM", None)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_READ_ONLY", False)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_FAILED", False)
monkeypatch.setattr(mcp_server, "_MCP_WRITER_LOCK_ERROR", "")
# First call: refused, latched read-only for reporting.
ok_first, reason_first = mcp_server._acquire_mcp_writer_lock()
assert ok_first is False
assert mcp_server._MCP_WRITER_READ_ONLY is True
assert "already holds" in reason_first
# Second call: the sticky latch must NOT short-circuit — retry succeeds.
ok_second, reason_second = mcp_server._acquire_mcp_writer_lock()
assert ok_second is True
assert reason_second == ""
assert calls["count"] == 2 # retried, not stranded read-only
assert mcp_server._MCP_WRITER_LOCK_CM is not None
assert mcp_server._MCP_WRITER_READ_ONLY is False
def test_sqlite_integrity_gate_refuses_non_status_tool(monkeypatch):
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(
mcp_server,
"_sqlite_integrity_errors",
["malformed inverted index for FTS5 table main.embedding_fulltext_search"],
)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
response = mcp_server.handle_request(
{
"jsonrpc": "2.0",
"id": 1818,
"method": "tools/call",
"params": {"name": "mempalace_list_wings", "arguments": {}},
}
)
assert response["error"]["code"] == mcp_server._SQLITE_INTEGRITY_ERROR_CODE
assert "integrity check failed" in response["error"]["message"]
assert response["error"]["data"]["tool"] == "mempalace_list_wings"
assert "malformed inverted index" in response["error"]["data"]["errors"][0]
def test_sqlite_integrity_status_surfaces_payload_without_chroma(monkeypatch):
import json
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(
mcp_server,
"_sqlite_integrity_errors",
["malformed inverted index for FTS5 table main.embedding_fulltext_search"],
)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
monkeypatch.setattr(
mcp_server,
"_tool_status_via_sqlite",
lambda: {"total_drawers": 123, "backend": "chroma"},
)
response = mcp_server.handle_request(
{
"jsonrpc": "2.0",
"id": 1819,
"method": "tools/call",
"params": {"name": "mempalace_status", "arguments": {}},
}
)
payload = json.loads(response["result"]["content"][0]["text"])
assert payload["total_drawers"] == 123
assert payload["sqlite_integrity_failed"] is True
assert payload["sqlite_integrity"]["ok"] is False
assert payload["sqlite_integrity"]["error_count"] == 1
assert "malformed inverted index" in payload["sqlite_integrity"]["errors"][0]
def test_sqlite_integrity_payload_not_applicable_on_non_chroma_backend(monkeypatch):
"""#1931: a non-chroma backend runs no sqlite quick_check, so status must
report the check as not-applicable rather than implying it passed.
Before the fix the payload reported ``checked=True``/``ok=True`` and a
``chroma.sqlite3`` path that does not exist for the active backend.
"""
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_selected_backend_name", lambda: "qdrant")
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
payload = mcp_server._sqlite_integrity_payload()
assert payload["checked"] is False
assert payload["ok"] is None
assert "qdrant" in payload["reason"]
# No chroma.sqlite3 reference and a shape stable with the chroma payload.
assert payload["sqlite_path"] == ""
assert payload["error_count"] == 0
assert payload["errors"] == []
def test_sqlite_integrity_payload_reports_unknown_when_backend_unresolvable(monkeypatch):
"""#1931: if backend resolution raises, status still must not claim an
integrity pass; it reports not-applicable for an unknown backend.
"""
from mempalace import mcp_server
def _boom():
raise RuntimeError("backend registry unavailable")
monkeypatch.setattr(mcp_server, "_selected_backend_name", _boom)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
payload = mcp_server._sqlite_integrity_payload()
assert payload["checked"] is False
assert payload["ok"] is None
assert "unknown" in payload["reason"]
def test_sqlite_integrity_payload_full_shape_on_chroma_backend(monkeypatch):
"""#1931 guard: a chroma backend with no recorded errors must still return
the full integrity payload; the not-applicable branch must not swallow the
chroma path.
"""
from mempalace import mcp_server
monkeypatch.setattr(mcp_server, "_selected_backend_name", lambda: "chroma")
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
payload = mcp_server._sqlite_integrity_payload()
assert payload["checked"] is True
assert payload["ok"] is True
assert "sqlite_path" in payload
assert payload["error_count"] == 0
assert "reason" not in payload
def test_sqlite_integrity_reconnect_allowed_when_corrupt(monkeypatch):
from mempalace import mcp_server
called = {"value": False}
def fake_reconnect():
called["value"] = True
return {"success": True}
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(
mcp_server,
"_sqlite_integrity_errors",
["malformed inverted index for FTS5 table main.embedding_fulltext_search"],
)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
monkeypatch.setitem(
mcp_server.TOOLS,
"mempalace_reconnect",
{
"description": "test reconnect",
"input_schema": {"type": "object", "properties": {}},
"handler": fake_reconnect,
},
)
response = mcp_server.handle_request(
{
"jsonrpc": "2.0",
"id": 1820,
"method": "tools/call",
"params": {"name": "mempalace_reconnect", "arguments": {}},
}
)
assert called["value"] is True
assert '"success": true' in response["result"]["content"][0]["text"]
def test_refresh_sqlite_integrity_status_records_quick_check_errors(monkeypatch):
from mempalace import mcp_server, repair
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
repair,
"sqlite_integrity_errors",
lambda palace_path: [
"malformed inverted index for FTS5 table main.embedding_fulltext_search"
],
)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert mcp_server._sqlite_integrity_checked is True
assert len(mcp_server._sqlite_integrity_errors) == 1
assert "malformed inverted index" in mcp_server._sqlite_integrity_errors[0]
def test_refresh_sqlite_integrity_status_skips_oversized_db(monkeypatch, tmp_path):
"""Oversized chroma.sqlite3 must NOT run the O(size) startup quick_check."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (2 * 1024 * 1024)) # 2 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "1") # limit 1 MB < 2 MB
called = {"n": 0}
def _boom(palace_path):
called["n"] += 1
raise AssertionError("quick_check must not run for oversized DB")
monkeypatch.setattr(repair, "sqlite_integrity_errors", _boom)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", ["stale"])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 0
assert mcp_server._sqlite_integrity_checked is True
assert mcp_server._sqlite_integrity_errors == []
def test_refresh_sqlite_integrity_status_runs_when_under_limit(monkeypatch, tmp_path):
"""A DB under the limit still runs the quick_check (behaviour preserved)."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (512 * 1024)) # 0.5 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "1") # limit 1 MB > 0.5 MB
called = {"n": 0}
def _spy(palace_path):
called["n"] += 1
return []
monkeypatch.setattr(repair, "sqlite_integrity_errors", _spy)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 1
assert mcp_server._sqlite_integrity_checked is True
def test_startup_integrity_size_gate_disabled_with_zero(monkeypatch, tmp_path):
"""MEMPALACE_STARTUP_INTEGRITY_MAX_MB=0 disables the gate: check always runs."""
from mempalace import mcp_server, repair
(tmp_path / "chroma.sqlite3").write_bytes(b"\0" * (4 * 1024 * 1024)) # 4 MB
monkeypatch.setattr(mcp_server, "_is_chroma_backend", lambda: True)
monkeypatch.setattr(
type(mcp_server._config), "palace_path", property(lambda self: str(tmp_path))
)
monkeypatch.setenv("MEMPALACE_STARTUP_INTEGRITY_MAX_MB", "0")
called = {"n": 0}
def _spy(palace_path):
called["n"] += 1
return []
monkeypatch.setattr(repair, "sqlite_integrity_errors", _spy)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", False)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", [])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
mcp_server._refresh_sqlite_integrity_status()
assert called["n"] == 1
def test_sqlite_integrity_refusal_handles_none_palace_path(monkeypatch):
"""
Regression test for Gemini review feedback on PR #1823 (lines 433-455).
_mcp_sqlite_integrity_refusal() must not raise TypeError when
_config.palace_path is None — os.path.join(None, "chroma.sqlite3")
would otherwise crash the server on every mutating tool call while
the palace is unconfigured and integrity errors are present.
"""
from mempalace import mcp_server
# palace_path is a read-only @property on MempalaceConfig (no setter),
# so monkeypatch.setattr on the instance fails. Patch the class-level
# property instead -- monkeypatch restores it automatically on teardown.
monkeypatch.setattr(type(mcp_server._config), "palace_path", property(lambda self: None))
monkeypatch.setattr(mcp_server, "_sqlite_integrity_checked", True)
monkeypatch.setattr(mcp_server, "_sqlite_integrity_errors", ["malformed inverted index"])
monkeypatch.setattr(mcp_server, "_sqlite_integrity_check_error", "")
# Must not raise
result = mcp_server._mcp_sqlite_integrity_refusal(req_id=1, tool_name="mempalace_kg_add")
assert result is not None
assert result["error"]["data"]["palace"] == ""
assert result["error"]["data"]["sqlite_path"] == ""
assert result["error"]["data"]["tool"] == "mempalace_kg_add"
class TestListDrawersDateFilters:
"""Unit tests for the #1128 date-filter helpers in mcp_server."""
def test_parse_date_filter_none_and_blank(self):
from mempalace.mcp_server import _parse_date_filter
assert _parse_date_filter(None, "since") is None
assert _parse_date_filter(" ", "since") is None
def test_parse_date_filter_date_only(self):
from datetime import datetime
from mempalace.mcp_server import _parse_date_filter
assert _parse_date_filter("2026-04-01", "since") == datetime(2026, 4, 1)
def test_parse_date_filter_full_timestamp(self):
from datetime import datetime
from mempalace.mcp_server import _parse_date_filter
assert _parse_date_filter("2026-04-01T09:30:00", "since") == datetime(2026, 4, 1, 9, 30)
def test_parse_date_filter_drops_timezone(self):
from datetime import datetime
from mempalace.mcp_server import _parse_date_filter
# tz offset dropped -> naive wall-clock, never raises vs naive filed_at.
parsed = _parse_date_filter("2026-04-01T09:30:00+02:00", "since")
assert parsed == datetime(2026, 4, 1, 9, 30)
assert parsed.tzinfo is None
def test_parse_date_filter_rejects_garbage(self):
import pytest
from mempalace.mcp_server import _parse_date_filter
with pytest.raises(ValueError, match="since"):
_parse_date_filter("not-a-date", "since")
def test_parse_date_filter_rejects_impossible_date(self):
import pytest
from mempalace.mcp_server import _parse_date_filter
with pytest.raises(ValueError):
_parse_date_filter("2026-13-40", "before")
def test_filed_at_in_window_since_inclusive(self):
from datetime import datetime
from mempalace.mcp_server import _filed_at_in_window
since = datetime(2026, 1, 2)
assert _filed_at_in_window("2026-01-02T00:00:00", since, None) is True
assert _filed_at_in_window("2026-01-01T23:59:59", since, None) is False
def test_filed_at_in_window_before_exclusive(self):
from datetime import datetime
from mempalace.mcp_server import _filed_at_in_window
before = datetime(2026, 1, 3)
assert _filed_at_in_window("2026-01-02T23:59:59", None, before) is True
assert _filed_at_in_window("2026-01-03T00:00:00", None, before) is False
def test_filed_at_in_window_missing_or_malformed_excluded(self):
from datetime import datetime
from mempalace.mcp_server import _filed_at_in_window
since = datetime(2026, 1, 1)
assert _filed_at_in_window(None, since, None) is False
assert _filed_at_in_window("", since, None) is False
assert _filed_at_in_window("garbage", since, None) is False
assert _filed_at_in_window(12345, since, None) is False
def test_filed_at_in_window_tz_aware_wall_clock(self):
from datetime import datetime
from mempalace.mcp_server import _filed_at_in_window
# tz dropped on both sides -> wall-clock compare, no TypeError raised.
since = datetime(2026, 1, 2)
assert _filed_at_in_window("2026-01-02T08:00:00+05:00", since, None) is True
def test_parse_date_filter_accepts_zulu_suffix(self):
from datetime import datetime
from mempalace.mcp_server import _parse_date_filter
# "Z" is not accepted by datetime.fromisoformat before 3.11; the helper
# strips it so Zulu inputs parse on the 3.9 floor, tz then dropped.
parsed = _parse_date_filter("2026-04-01T09:30:00Z", "since")
assert parsed == datetime(2026, 4, 1, 9, 30)
assert parsed.tzinfo is None
# Date-only with a Zulu suffix must also parse on 3.9/3.10 (appending
# "+00:00" would have raised there; stripping Z does not).
parsed_date = _parse_date_filter("2026-04-01Z", "since")
assert parsed_date == datetime(2026, 4, 1)
assert parsed_date.tzinfo is None
# Lowercase z is tolerated too.
assert _parse_date_filter("2026-04-01t09:30:00z", "since") == datetime(2026, 4, 1, 9, 30)
def test_filed_at_in_window_accepts_zulu_filed_at(self):
from datetime import datetime
from mempalace.mcp_server import _filed_at_in_window
since = datetime(2026, 1, 2)
assert _filed_at_in_window("2026-01-02T08:00:00Z", since, None) is True