Skip to content

Commit 41359ba

Browse files
jpheinclaude
andcommitted
feat(mcp): daemon-route mcp_server.py via handle_request chokepoint
Mirror the PALACE_DAEMON_URL gate that hooks_cli.py shipped on 2026-04-24 (the daemon-strict fix for the HNSW drift incident). When PALACE_DAEMON_URL is set and PALACE_DAEMON_STRICT != "0", every JSON-RPC method (initialize, tools/list, tools/call, ping) is forwarded to palace-daemon's /mcp proxy and the daemon's response is returned verbatim. Single chokepoint at handle_request() is functionally equivalent to per-handler gates and avoids 30+ duplicated branches — no local chromadb client opens in strict mode. Startup HNSW probe is skipped when daemon-strict (the daemon owns its palace's capacity). Closes the last in-process write path that bypassed the daemon, so the standalone palace-daemon/clients/mempalace-mcp.py bridge is now optional — anyone running `python -m mempalace.mcp_server` with the env var set gets the same behavior natively. tests/conftest.py scrubs PALACE_DAEMON_URL/STRICT/API_KEY at module load (matching the existing HOME-redirect pattern) so existing local-path tests don't accidentally hit the live daemon when run from a shell where the env var is set. 15 new tests in tests/test_mcp_server_daemon.py covering the gate, helper body shape, network-failure surfacing as JSON-RPC error, forwarded initialize/tools/call/error propagation, and a sentinel TOOLS patch proving no local handler runs in strict mode. End-to-end smoke against disks.jphe.in:8085 returns 160,351 drawers from the canonical palace. Suite 1577 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b2b4fb3 commit 41359ba

3 files changed

Lines changed: 410 additions & 4 deletions

File tree

mempalace/mcp_server.py

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,76 @@ def _refresh_vector_disabled_flag() -> None:
166166
_vector_disabled_reason = ""
167167

168168

169+
# ==================== DAEMON ROUTING ====================
170+
# When ``PALACE_DAEMON_URL`` is set, palace-daemon is the single writer
171+
# for the canonical palace and we route every JSON-RPC envelope to its
172+
# ``/mcp`` proxy instead of opening a local chromadb client. Mirrors the
173+
# gate in :mod:`mempalace.hooks_cli` (the mining side). The 2026-04-24
174+
# HNSW-drift incident traced to dual writers (hook subprocesses +
175+
# Syncthing replication) writing the same palace from two hosts; the
176+
# fix is "daemon is the single writer", and that fix is incomplete as
177+
# long as ``mcp_server`` can still open chromadb directly. Folding the
178+
# routing in here makes the stand-alone bridge at
179+
# ``palace-daemon/clients/mempalace-mcp.py`` optional — anyone running
180+
# ``python -m mempalace.mcp_server`` with the env var set gets the same
181+
# behavior natively.
182+
183+
_DAEMON_FORWARD_TIMEOUT_DEFAULT = 120 # seconds
184+
185+
186+
def _daemon_strict() -> bool:
187+
"""True when ``PALACE_DAEMON_URL`` is set and strict mode is enabled.
188+
189+
Set ``PALACE_DAEMON_STRICT=0`` to opt out and force the local-palace
190+
path even when the daemon URL is configured (useful when running
191+
the test suite or doing offline development).
192+
"""
193+
return (
194+
os.environ.get("PALACE_DAEMON_URL", "").strip() != ""
195+
and os.environ.get("PALACE_DAEMON_STRICT", "1") != "0"
196+
)
197+
198+
199+
def _forward_to_daemon(request: dict) -> dict:
200+
"""POST a JSON-RPC envelope to palace-daemon's ``/mcp`` proxy.
201+
202+
Returns the parsed response. On any failure (network, non-JSON body),
203+
returns a JSON-RPC error envelope — strict mode never silently falls
204+
back to local, since that would re-introduce the split-brain that
205+
daemon-strict was created to prevent.
206+
"""
207+
daemon_url = os.environ.get("PALACE_DAEMON_URL", "").strip().rstrip("/")
208+
req_id = request.get("id")
209+
try:
210+
import urllib.request
211+
212+
headers = {"content-type": "application/json"}
213+
api_key = os.environ.get("PALACE_API_KEY", "").strip()
214+
if api_key:
215+
headers["x-api-key"] = api_key
216+
req = urllib.request.Request(
217+
f"{daemon_url}/mcp",
218+
data=json.dumps(request).encode("utf-8"),
219+
headers=headers,
220+
method="POST",
221+
)
222+
raw_timeout = os.environ.get("PALACE_MCP_TIMEOUT", str(_DAEMON_FORWARD_TIMEOUT_DEFAULT))
223+
try:
224+
timeout = int(raw_timeout)
225+
except ValueError:
226+
timeout = _DAEMON_FORWARD_TIMEOUT_DEFAULT
227+
with urllib.request.urlopen(req, timeout=timeout) as resp:
228+
body = resp.read()
229+
return json.loads(body.decode("utf-8", errors="replace"))
230+
except Exception as e:
231+
logger.warning("palace-daemon /mcp forward failed: %s", e)
232+
return {
233+
"jsonrpc": "2.0",
234+
"id": req_id,
235+
"error": {"code": -32000, "message": f"Daemon unreachable: {e}"},
236+
}
237+
238+
169239
# ==================== WRITE-AHEAD LOG ====================
170240
# Every write operation is logged to a JSONL file before execution.
171241
# This provides an audit trail for detecting memory poisoning and
@@ -1907,6 +1977,17 @@ def handle_request(request):
19071977
params = request.get("params") or {}
19081978
req_id = request.get("id")
19091979

1980+
# Daemon-strict: forward the whole envelope to palace-daemon's /mcp
1981+
# proxy. Single chokepoint here is functionally equivalent to per-
1982+
# handler gates (initialize, tools/list, tools/call all flow
1983+
# through) and avoids 30+ duplicated branches inside the TOOLS
1984+
# dispatch below. Notifications skip both the daemon and local —
1985+
# JSON-RPC spec says they never get a response.
1986+
if _daemon_strict():
1987+
if method.startswith("notifications/") or req_id is None:
1988+
return None
1989+
return _forward_to_daemon(request)
1990+
19101991
if method == "initialize":
19111992
client_version = params.get("protocolVersion", SUPPORTED_PROTOCOL_VERSIONS[-1])
19121993
negotiated = (
@@ -2024,10 +2105,18 @@ def _restore_stdout():
20242105
def main():
20252106
_restore_stdout()
20262107
logger.info("MemPalace MCP Server starting...")
2027-
# Pre-flight: probe HNSW capacity before any tool call so the warning
2028-
# is visible at startup rather than on first use (#1222). Pure
2029-
# filesystem read; never opens a chromadb client.
2030-
_refresh_vector_disabled_flag()
2108+
if _daemon_strict():
2109+
logger.info(
2110+
"PALACE_DAEMON_URL=%s — routing all MCP traffic via daemon /mcp; "
2111+
"skipping local-palace startup probes.",
2112+
os.environ.get("PALACE_DAEMON_URL", "").strip(),
2113+
)
2114+
else:
2115+
# Pre-flight: probe HNSW capacity before any tool call so the warning
2116+
# is visible at startup rather than on first use (#1222). Pure
2117+
# filesystem read; never opens a chromadb client. Skipped in
2118+
# daemon-strict mode — the daemon owns its palace's capacity.
2119+
_refresh_vector_disabled_flag()
20312120
while True:
20322121
try:
20332122
line = sys.stdin.readline()

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@
2626
os.environ["HOMEDRIVE"] = os.path.splitdrive(_session_tmp)[0] or "C:"
2727
os.environ["HOMEPATH"] = os.path.splitdrive(_session_tmp)[1] or _session_tmp
2828

29+
# ── Unset daemon-routing env vars for the test session ────────────────
30+
# Tests build local-palace fixtures and exercise mcp_server's local
31+
# handlers directly. With ``PALACE_DAEMON_URL`` set in the developer's
32+
# shell env (e.g. http://disks.jphe.in:8085 in JP's case), the new
33+
# routing gate in :func:`mempalace.mcp_server.handle_request` would
34+
# forward those test calls to the live daemon and fail. Tests that
35+
# specifically exercise the daemon path (test_mcp_server_daemon.py)
36+
# set the env explicitly via ``patch.dict``.
37+
for _daemon_var in ("PALACE_DAEMON_URL", "PALACE_DAEMON_STRICT", "PALACE_API_KEY"):
38+
_original_env[_daemon_var] = os.environ.get(_daemon_var)
39+
os.environ.pop(_daemon_var, None)
40+
2941
# Now it is safe to import mempalace modules that trigger initialisation.
3042
import chromadb # noqa: E402
3143
import pytest # noqa: E402

0 commit comments

Comments
 (0)