2020import http .client
2121import json
2222import logging
23+ import os
2324import socketserver
2425import ssl
2526import threading
@@ -201,7 +202,7 @@ def test_bearer_token_enforced_when_configured(monkeypatch):
201202
202203
203204def 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" ,
0 commit comments