Skip to content

Commit 6ec2030

Browse files
authored
Merge pull request #2126 from MemPalace/fix/2103-onto-develop
fix(mcp): refuse config and ack writes in read-only mode (#2103)
2 parents a929e17 + 217de20 commit 6ec2030

5 files changed

Lines changed: 248 additions & 14 deletions

File tree

mempalace/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2189,7 +2189,7 @@ def main():
21892189
p_serve.add_argument(
21902190
"--read-only",
21912191
action="store_true",
2192-
help="Expose recall only: mutating tools are hidden and refused",
2192+
help="Expose recall only: tools that change state are hidden and refused",
21932193
)
21942194
p_serve.add_argument(
21952195
"--allow-insecure",

mempalace/mcp_server.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,8 @@ def _parse_args():
290290
parser.add_argument(
291291
"--read-only",
292292
action="store_true",
293-
help="Serve a read-only tool surface: the mutating tools are hidden from "
294-
"tools/list and refused at dispatch (env MEMPALACE_MCP_READ_ONLY)",
293+
help="Serve a read-only tool surface: the tools that change state are hidden "
294+
"from tools/list and refused at dispatch (env MEMPALACE_MCP_READ_ONLY)",
295295
)
296296
args, unknown = parser.parse_known_args()
297297
if unknown:
@@ -313,10 +313,12 @@ def _parse_args():
313313

314314
_config = MempalaceConfig()
315315

316-
# Read-only server mode: when on, the mutating tools are hidden from tools/list
317-
# and refused at dispatch (-32003). Resolved once at startup from --read-only or
318-
# MEMPALACE_MCP_READ_ONLY. Computed inline (not via _truthy_env, defined below)
319-
# so it is available to the request path regardless of import order.
316+
# Read-only server mode: when on, the tools in _READ_ONLY_REFUSED_TOOLS (defined
317+
# below) are hidden from tools/list and refused at dispatch (-32003). That is a
318+
# wider set than the _MUTATING_TOOLS the peer-writer guard uses. Resolved once at
319+
# startup from --read-only or MEMPALACE_MCP_READ_ONLY. Computed inline (not via
320+
# _truthy_env, defined below) so it is available to the request path regardless
321+
# of import order.
320322
_READ_ONLY = bool(getattr(_args, "read_only", False)) or os.environ.get(
321323
"MEMPALACE_MCP_READ_ONLY", ""
322324
).strip().lower() in {"1", "true", "yes", "on"}
@@ -403,6 +405,42 @@ def _parse_args():
403405
}
404406
)
405407

408+
# Read-only mode (#1877) refuses a wider set than the peer-writer guard above.
409+
#
410+
# _MUTATING_TOOLS is the *palace-write* set: _mcp_peer_writer_refusal consults it
411+
# to decide which calls need this process to hold the palace mine lock. A tool
412+
# that never touches Chroma or the knowledge graph has to stay out of that set,
413+
# or a server that lost the lease to a peer would start refusing calls the lease
414+
# has no say over.
415+
#
416+
# Two tools are exactly that shape, and read-only has to name both because it is
417+
# a capability boundary rather than a lock: it exists so a shared server can
418+
# serve recall to a client that must not change server state.
419+
#
420+
# mempalace_hook_settings, given an argument, writes the server's
421+
# ~/.mempalace/config.json through MempalaceConfig.set_hook_setting.
422+
# service.WRITE_TOOLS already classifies it as a write, which the daemon uses
423+
# as an allowlist, so read-only was the odd one out.
424+
#
425+
# mempalace_memories_filed_away unlinks ~/.mempalace/hook_state/last_checkpoint
426+
# on both of its branches. Consuming the file is the contract of the tool, but
427+
# it is still a delete of state that outlives the process, on behalf of a
428+
# client with no write access. (service.classify_tool calls this one "read",
429+
# which is wrong for the same reason.)
430+
#
431+
# mempalace_reconnect is deliberately NOT here even though it is not write-free:
432+
# it clears ChromaBackend._quarantined_paths, so the reopen that follows can let
433+
# quarantine_stale_hnsw rename a segment directory. It is the only way to pick up
434+
# an external writer's changes, and _SQLITE_INTEGRITY_ALLOWED_TOOLS already keeps
435+
# it reachable for recovery, so gating it would strand a read-only server on a
436+
# stale index. This set means "refuse what a client asked to change", not
437+
# "nothing past here touches the disk" -- opening the palace or the knowledge
438+
# graph materialises files on its own, which no name-based gate can express.
439+
_READ_ONLY_REFUSED_TOOLS = _MUTATING_TOOLS | {
440+
"mempalace_hook_settings",
441+
"mempalace_memories_filed_away",
442+
}
443+
406444

407445
def _truthy_env(name: str) -> bool:
408446
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
@@ -4816,15 +4854,19 @@ def _internal_tool_error(req_id, tool_name: str, exc: BaseException = None) -> d
48164854

48174855

48184856
def _mcp_read_only_refusal(req_id, tool_name: str):
4819-
"""Refuse mutating tools when the server runs in read-only mode (#1877).
4857+
"""Refuse state-changing tools when the server runs in read-only mode (#1877).
48204858
48214859
Read-only is an operator-set server mode (``--read-only`` /
48224860
``MEMPALACE_MCP_READ_ONLY``), distinct from the dynamic peer-writer lock:
48234861
it is an unconditional gate so a shared team server can expose recall
48244862
without write access. Enforced at dispatch, not merely hidden from
48254863
tools/list, so a client that calls a mutating tool by name is still refused.
4864+
4865+
Gates on ``_READ_ONLY_REFUSED_TOOLS``, not ``_MUTATING_TOOLS``: a tool can
4866+
write outside the palace database, which the peer-writer lease has no reason
4867+
to arbitrate but read-only still has to refuse.
48264868
"""
4827-
if not _READ_ONLY or tool_name not in _MUTATING_TOOLS:
4869+
if not _READ_ONLY or tool_name not in _READ_ONLY_REFUSED_TOOLS:
48284870
return None
48294871

48304872
return {
@@ -4896,16 +4938,17 @@ def handle_request(request):
48964938
# Notifications (no id) never get a response per JSON-RPC spec
48974939
return None
48984940
elif method == "tools/list":
4899-
# In read-only mode, hide the mutating tools so clients don't advertise
4941+
# In read-only mode, hide the refused tools so clients don't advertise
49004942
# write capabilities they can't use (dispatch also refuses them, #1877).
4943+
# Same set on both sides, or a tool would be listed and then rejected.
49014944
return {
49024945
"jsonrpc": "2.0",
49034946
"id": req_id,
49044947
"result": {
49054948
"tools": [
49064949
{"name": n, "description": t["description"], "inputSchema": t["input_schema"]}
49074950
for n, t in TOOLS.items()
4908-
if not (_READ_ONLY and n in _MUTATING_TOOLS)
4951+
if not (_READ_ONLY and n in _READ_ONLY_REFUSED_TOOLS)
49094952
]
49104953
},
49114954
}

tests/test_mcp_http_transport.py

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import http.client
2121
import json
2222
import logging
23+
import os
2324
import socketserver
2425
import ssl
2526
import threading
@@ -201,7 +202,7 @@ def test_bearer_token_enforced_when_configured(monkeypatch):
201202

202203

203204
def test_read_only_hides_and_refuses_mutating_tools(http_server, monkeypatch):
204-
"""Read-only mode (#1877): mutating tools are hidden from tools/list AND
205+
"""Read-only mode (#1877): the refused tools are hidden from tools/list AND
205206
refused at dispatch with -32003, while read tools still work."""
206207
monkeypatch.setattr(mcp, "_READ_ONLY", True)
207208
port, _ = http_server
@@ -211,7 +212,7 @@ def test_read_only_hides_and_refuses_mutating_tools(http_server, monkeypatch):
211212
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
212213
assert "mempalace_search" in names # read tool stays
213214
assert "mempalace_add_drawer" not in names # mutating tool hidden
214-
assert names.isdisjoint(mcp._MUTATING_TOOLS)
215+
assert names.isdisjoint(mcp._READ_ONLY_REFUSED_TOOLS)
215216

216217
status, body = _post(
217218
port,
@@ -332,6 +333,117 @@ def fail_bind(host, port):
332333
assert events == ["bind-failed", "discard", "lease-exit"]
333334
assert mcp._MCP_WRITER_LOCK_CM is None
334335

336+
def _hook_settings_call(req_id):
337+
return {
338+
"jsonrpc": "2.0",
339+
"id": req_id,
340+
"method": "tools/call",
341+
"params": {
342+
"name": "mempalace_hook_settings",
343+
"arguments": {"silent_save": False, "desktop_toast": True},
344+
},
345+
}
346+
347+
348+
def test_read_only_refuses_the_hook_settings_config_write(http_server, monkeypatch, tmp_path):
349+
"""mempalace_hook_settings writes the server's ~/.mempalace/config.json.
350+
351+
It touches no palace state, so it is correctly absent from _MUTATING_TOOLS,
352+
the palace-write set the peer-writer lease arbitrates. Read-only gated on
353+
that set, which let a read-only server persist a config change on behalf of
354+
a client that is supposed to have no write access at all.
355+
356+
The first half is the control: it proves the write really does land here, so
357+
the "unchanged" assertion in the second half cannot pass vacuously.
358+
"""
359+
home = tmp_path / "home"
360+
(home / ".mempalace").mkdir(parents=True)
361+
cfg_file = home / ".mempalace" / "config.json"
362+
cfg_file.write_text(
363+
json.dumps({"hooks": {"silent_save": True, "desktop_toast": False}}), encoding="utf-8"
364+
)
365+
monkeypatch.setenv("HOME", str(home))
366+
monkeypatch.setenv("USERPROFILE", str(home))
367+
monkeypatch.setenv("HOMEDRIVE", os.path.splitdrive(str(home))[0] or "C:")
368+
monkeypatch.setenv("HOMEPATH", os.path.splitdrive(str(home))[1] or str(home))
369+
pristine = cfg_file.read_bytes()
370+
371+
port, _ = http_server
372+
373+
# Control: the gate is off, so the very same call rewrites config.json.
374+
# _READ_ONLY is resolved at import from the environment, so pin it rather
375+
# than inherit whatever the suite was started with.
376+
monkeypatch.setattr(mcp, "_READ_ONLY", False)
377+
status, body = _post(port, "/mcp", _hook_settings_call(1))
378+
assert status == 200
379+
# The handler reports its own failures inside `result` as {"success": false},
380+
# not as a JSON-RPC error, so check the payload rather than just the envelope.
381+
payload = json.loads(body)
382+
assert "error" not in payload
383+
assert json.loads(payload["result"]["content"][0]["text"])["success"] is True
384+
assert cfg_file.read_bytes() != pristine
385+
cfg_file.write_bytes(pristine)
386+
387+
# Gate on: hidden from tools/list, refused at dispatch, file left alone.
388+
monkeypatch.setattr(mcp, "_READ_ONLY", True)
389+
390+
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
391+
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
392+
assert "mempalace_hook_settings" not in names
393+
394+
status, body = _post(port, "/mcp", _hook_settings_call(3))
395+
assert status == 200
396+
assert json.loads(body)["error"]["code"] == -32003
397+
assert cfg_file.read_bytes() == pristine
398+
399+
400+
def test_read_only_refuses_the_checkpoint_ack_delete(http_server, monkeypatch, tmp_path):
401+
"""mempalace_memories_filed_away unlinks the Stop hook's checkpoint ack file.
402+
403+
Consuming that file is the contract of the tool, but it is still a delete of
404+
state that outlives the process, done for a client with no write access. Same
405+
two-phase shape as the config test: the control proves the delete lands, so
406+
the survival assertion afterwards cannot pass vacuously.
407+
"""
408+
home = tmp_path / "home"
409+
state_dir = home / ".mempalace" / "hook_state"
410+
state_dir.mkdir(parents=True)
411+
ack = state_dir / "last_checkpoint"
412+
ack.write_text(json.dumps({"msgs": 7, "ts": "2026-01-01T00:00:00"}), encoding="utf-8")
413+
monkeypatch.setenv("HOME", str(home))
414+
monkeypatch.setenv("USERPROFILE", str(home))
415+
monkeypatch.setenv("HOMEDRIVE", os.path.splitdrive(str(home))[0] or "C:")
416+
monkeypatch.setenv("HOMEPATH", os.path.splitdrive(str(home))[1] or str(home))
417+
418+
port, _ = http_server
419+
call = {
420+
"jsonrpc": "2.0",
421+
"id": 1,
422+
"method": "tools/call",
423+
"params": {"name": "mempalace_memories_filed_away", "arguments": {}},
424+
}
425+
426+
# Control: the gate is off, so the call consumes the ack file.
427+
monkeypatch.setattr(mcp, "_READ_ONLY", False)
428+
status, body = _post(port, "/mcp", call)
429+
assert status == 200
430+
assert json.loads(json.loads(body)["result"]["content"][0]["text"])["count"] == 7
431+
assert not ack.exists()
432+
433+
# Gate on: refused, and a fresh ack file survives untouched.
434+
ack.write_text(json.dumps({"msgs": 7, "ts": "2026-01-01T00:00:00"}), encoding="utf-8")
435+
pristine = ack.read_bytes()
436+
monkeypatch.setattr(mcp, "_READ_ONLY", True)
437+
438+
status, body = _post(port, "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
439+
names = {t["name"] for t in json.loads(body)["result"]["tools"]}
440+
assert "mempalace_memories_filed_away" not in names
441+
442+
status, body = _post(port, "/mcp", dict(call, id=3))
443+
assert status == 200
444+
assert json.loads(body)["error"]["code"] == -32003
445+
assert ack.read_bytes() == pristine
446+
335447

336448
@pytest.mark.parametrize(
337449
"disconnect_exc",

tests/test_mcp_server.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5300,6 +5300,85 @@ def forbidden_lock():
53005300
assert '"ok": true' in response["result"]["content"][0]["text"]
53015301

53025302

5303+
def test_read_only_refuses_exactly_the_refused_set(monkeypatch):
5304+
"""Ask the gate which tools it refuses instead of restating the set.
5305+
5306+
Comparing against the whole TOOLS registry also catches a stale name: a tool
5307+
renamed or removed while the set still lists it would gate nothing, and the
5308+
two sides would stop matching.
5309+
"""
5310+
from mempalace import mcp_server
5311+
5312+
monkeypatch.setattr(mcp_server, "_READ_ONLY", True)
5313+
5314+
refused = {
5315+
name for name in mcp_server.TOOLS if mcp_server._mcp_read_only_refusal(1, name) is not None
5316+
}
5317+
assert refused == set(mcp_server._READ_ONLY_REFUSED_TOOLS)
5318+
assert "mempalace_hook_settings" in refused
5319+
assert "mempalace_memories_filed_away" in refused
5320+
# Reconnect stays reachable on purpose: it is the only way a read-only
5321+
# server picks up an external writer's changes.
5322+
assert "mempalace_reconnect" not in refused
5323+
5324+
# The palace-write set the peer-writer lease arbitrates stays the narrower
5325+
# of the two; see test_peer_writer_guard_does_not_gate_hook_settings.
5326+
assert mcp_server._MUTATING_TOOLS < mcp_server._READ_ONLY_REFUSED_TOOLS
5327+
assert "mempalace_hook_settings" not in mcp_server._MUTATING_TOOLS
5328+
5329+
5330+
def test_read_only_refuses_every_daemon_write_tool():
5331+
"""Read-only must not be laxer than the daemon's own write classification.
5332+
5333+
service.WRITE_TOOLS is a security allowlist: execute_job lets the generic
5334+
mcp_tool escape hatch run write-classified tools only. A tool the daemon
5335+
calls a write while read-only serves it is the exact gap this fixes, and
5336+
mempalace_hook_settings was that tool.
5337+
"""
5338+
from mempalace import mcp_server, service
5339+
5340+
assert service.WRITE_TOOLS <= mcp_server._READ_ONLY_REFUSED_TOOLS
5341+
assert "mempalace_hook_settings" in service.WRITE_TOOLS
5342+
5343+
5344+
def test_peer_writer_guard_does_not_gate_hook_settings(monkeypatch):
5345+
"""The read-only widening must not leak into the peer-writer path.
5346+
5347+
mempalace_hook_settings writes the config file and never the palace, so it
5348+
stays out of _MUTATING_TOOLS and the lease has no say over it. Read-only
5349+
refuses it through _READ_ONLY_REFUSED_TOOLS instead. Were it moved into
5350+
_MUTATING_TOOLS, a peer holding the lease would refuse it with -32001,
5351+
including the no-argument form that only reads the current settings.
5352+
"""
5353+
from mempalace import mcp_server
5354+
5355+
def forbidden_lock():
5356+
raise AssertionError("hook_settings should not acquire the peer-writer lock")
5357+
5358+
monkeypatch.setitem(
5359+
mcp_server.TOOLS,
5360+
"mempalace_hook_settings",
5361+
{
5362+
"description": "test config tool",
5363+
"input_schema": {"type": "object", "properties": {}},
5364+
"handler": lambda: {"ok": True},
5365+
},
5366+
)
5367+
monkeypatch.setattr(mcp_server, "_acquire_mcp_writer_lock", forbidden_lock)
5368+
5369+
response = mcp_server.handle_request(
5370+
{
5371+
"jsonrpc": "2.0",
5372+
"id": 9,
5373+
"method": "tools/call",
5374+
"params": {"name": "mempalace_hook_settings", "arguments": {}},
5375+
}
5376+
)
5377+
5378+
assert '"ok": true' in response["result"]["content"][0]["text"]
5379+
assert "mempalace_hook_settings" not in mcp_server._MUTATING_TOOLS
5380+
5381+
53035382
def test_status_tool_does_not_acquire_peer_writer_lock(monkeypatch):
53045383
from mempalace import mcp_server
53055384

website/guide/remote-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ Output includes the token and the exact client command. Useful flags:
132132
| `--port` | `8765` | Listen port |
133133
| `--backend` | config/env | Storage backend (e.g. `qdrant`) |
134134
| `--tls-cert` / `--tls-key` | _(none)_ | PEM cert + key to terminate **TLS natively** (server speaks `https`) |
135-
| `--read-only` | off | Expose recall only — the mutating tools are hidden and refused |
135+
| `--read-only` | off | Expose recall only — the tools that change state are hidden and refused |
136136
| `--token` | auto | Use a specific bearer token instead of the generated one |
137137
| `--allow-insecure` | off | Permit a non-loopback bind with no token (only behind a trusted proxy) |
138138

0 commit comments

Comments
 (0)