-
Notifications
You must be signed in to change notification settings - Fork 7.5k
Expand file tree
/
Copy pathmcp_server.py
More file actions
7460 lines (6655 loc) · 295 KB
/
Copy pathmcp_server.py
File metadata and controls
7460 lines (6655 loc) · 295 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
MemPalace MCP Server — read/write palace access for Claude Code
================================================================
Install: claude mcp add mempalace -- mempalace-mcp [--palace /path/to/palace]
Tools (read):
mempalace_status — total drawers, wing/room breakdown
mempalace_list_wings — all wings with drawer counts
mempalace_list_rooms — rooms within a wing
mempalace_get_taxonomy — full wing → room → count tree
mempalace_search — semantic search, optional wing/room/source_file filter
mempalace_check_duplicate — check if content already exists before filing
Tools (write):
mempalace_add_drawer — file verbatim content into a wing/room
mempalace_delete_drawer — remove a drawer by ID
mempalace_delete_by_source — bulk-remove all drawers mined from one source_file
Tools (maintenance):
mempalace_reconnect — force cache invalidation and reconnect after external writes
"""
import os
import sys
# --- MCP stdio protection (issue #225) -----------------------------------
# The MCP protocol multiplexes JSON-RPC over stdio: stdout MUST carry only
# valid JSON-RPC messages, stderr is for human-readable logs. Some
# transitive dependencies (chromadb → onnxruntime, posthog telemetry) print
# banners and error messages directly to stdout — sometimes at C level —
# which breaks Claude Desktop's JSON parser. Redirect stdout → stderr at
# both the Python and file-descriptor level before heavy imports, then
# restore the real stdout in main() before entering the protocol loop.
_REAL_STDOUT = sys.stdout
_REAL_STDOUT_FD = None
try:
_REAL_STDOUT_FD = os.dup(1)
os.dup2(2, 1)
except (OSError, AttributeError):
# Environments without fd-level stdio (embedded interpreters, some test
# harnesses). The Python-level redirect below still applies.
pass
sys.stdout = sys.stderr
import argparse # noqa: E402 (deferred until after stdio protection above)
import contextlib # noqa: E402
import json # noqa: E402
import logging # noqa: E402
import re # noqa: E402
import hashlib # noqa: E402
import hmac # noqa: E402
import sqlite3 # noqa: E402
import threading # noqa: E402
import time # noqa: E402
from datetime import date, datetime # noqa: E402
from pathlib import Path # noqa: E402
from typing import Optional # noqa: E402
from urllib.parse import urlparse # noqa: E402
from .config import ( # noqa: E402
MempalaceConfig,
sanitize_kg_value,
sanitize_name,
sanitize_content,
sanitize_iso_temporal,
sqlite_read_uri,
strip_lone_surrogates,
)
from .version import __version__ # noqa: E402
from chromadb.errors import NotFoundError as _ChromaNotFoundError # noqa: E402
from .backends.chroma import ( # noqa: E402
ChromaBackend,
ChromaCollection,
_HNSW_WRITE_DEFAULTS,
_pin_hnsw_threads,
hnsw_capacity_status,
reset_hnsw_capacity_cache,
)
from .backends import BackendMismatchError, PalaceRef, detect_backend_for_path # noqa: E402
from .query_sanitizer import sanitize_query # noqa: E402
from .searcher import ( # noqa: E402
_distance_to_similarity,
_metric_for_collection,
search_memories,
)
from .palace_graph import ( # noqa: E402
traverse,
find_tunnels,
graph_stats,
create_tunnel,
list_tunnels,
delete_tunnel,
follow_tunnels,
)
from .hallways import ( # noqa: E402
list_hallways,
delete_hallway,
)
from .knowledge_graph import KnowledgeGraph, DEFAULT_KG_PATH # noqa: E402
from .logstream import LOGSTREAM_DB_FILENAME, Logstream # noqa: E402
from .collision_scan import assert_no_collisions # noqa: E402
from .ids import ID_RECIPE, make_drawer_id_from_content # noqa: E402
class _MempalaceLogFilter(logging.Filter):
"""Pass only records emitted by mempalace's own loggers.
Lets the ``MEMPALACE_LOG_FILE`` handler attach to an already-configured
root logger (a host app embedding the server, #1860) without copying the
host's — or a third-party library's — records into mempalace's diagnostic
file. mempalace loggers are ``mempalace`` / ``mempalace.*`` (the dotted
``__name__`` family) plus the flat ``mempalace_mcp`` /
``mempalace_format_miner`` / ``mempalace_hallways`` / ``mempalace_graph``
loggers — every one is prefixed ``mempalace``.
"""
def filter(self, record: logging.LogRecord) -> bool:
name = record.name
return name == "mempalace" or name.startswith(("mempalace.", "mempalace_"))
# Preserved across importlib.reload via globals(): a reload re-executes this
# module body, so a plain ``= False`` would reset the guard and let
# _init_logging() stack a duplicate file handler. globals().get keeps the prior
# True so the guard survives reload (#1885 review).
_logging_configured = globals().get("_logging_configured", False)
def _init_logging() -> None:
"""Configure mempalace logging: stderr by default, optional file append.
``MEMPALACE_LOG_FILE``, when set, attaches a ``FileHandler`` so MCP-client
failures the client never surfaces (e.g. the ``-32000`` cold-load timeout
in #1495) stay diagnosable from the file.
Root-logger ownership (#1860). The server must not hijack a host
application's logging, so the two cases are handled differently:
* **Root unconfigured** (standalone ``mempalace-mcp``): own it — a stderr
handler (plus the optional file handler) via ``basicConfig`` at INFO.
The historical behaviour.
* **Root already configured** (an app imported ``mempalace.mcp_server``
after setting up its own logging): leave the host's level, format, and
handlers untouched. Attach only the file handler, filtered to
mempalace's own records (`_MempalaceLogFilter`), so the host's logs do
not bleed into mempalace's file. With ``MEMPALACE_LOG_FILE`` unset the
root logger is not touched at all.
Previously this called ``logging.basicConfig(..., force=True)``, which
reset root's handlers/level/format unconditionally and silently clobbered
any host app that had configured logging first (#1860). ``force`` existed
(#1495) only to stop ``basicConfig`` no-op'ing when handlers already
existed; the filtered additive handler preserves that diagnostic contract
without the collateral reset.
The file handler is mempalace-filtered in both paths, so the file is a
clean mempalace-only stream. In the embedded path mempalace's records are
still subject to the host's root level — a host wanting INFO diagnostics in
the file should not raise root above INFO. The standalone path pins INFO.
Failure modes:
* Invalid path (missing directory, no perms, Windows NUL byte) → the file
handler is skipped with a warning naming ``MEMPALACE_LOG_FILE``; the
server still starts. ``ValueError`` is in the catch because Windows
raises it for embedded-NUL paths, not ``OSError``.
* Concurrent writers (multiple ``mempalace-mcp`` processes at one path)
interleave at the line level; append mode means nothing is overwritten,
but give each process its own path.
``delay=True`` is intentionally NOT set: deferring the open moves an
invalid-path error to ``emit()`` time (unhandled), defeating the fail-soft
contract. Eager open lands the same error in ``FileHandler.__init__`` and
our ``except`` below.
Runs at import time (module-level call below) so importing the module for
introspection (``TOOLS`` dict, handler functions) configures logging once.
"""
global _logging_configured
if _logging_configured:
# Idempotent: a second call (e.g. importlib.reload) must not add a
# duplicate file handler in the embedded path.
return
_logging_configured = True
# MEMPALACE_LOG_FILE is operator-supplied and opt-in; this is a
# local-first server (CLAUDE.md design principle), so no path
# sanitization — the operator's process UID is the trust boundary.
log_file = os.environ.get("MEMPALACE_LOG_FILE", "").strip()
file_handler: logging.Handler | None = None
file_handler_error: Exception | None = None
if log_file:
try:
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
# Pin the format: the embedded path never calls basicConfig, so set
# it here instead of relying on logging's default formatter. The
# default already renders "%(message)s", but the explicit set makes
# both paths identical and independent of that default (#1885 review).
file_handler.setFormatter(logging.Formatter("%(message)s"))
# File is a mempalace-only diagnostic stream; keep host / library
# records out so it stays useful when the handler rides on a
# host-owned root logger (#1860).
file_handler.addFilter(_MempalaceLogFilter())
except (OSError, ValueError) as exc:
# Fail-soft: see "Invalid path" failure mode above. Broad on
# (OSError, ValueError) because Windows raises ValueError for
# NUL-byte paths while POSIX uses OSError for missing-dir / EPERM.
file_handler_error = exc
root = logging.getLogger()
if root.handlers:
# A host app (or a transitive import) already owns root logging. Do
# NOT reset it (#1860) — only add our filtered file handler, if any.
if file_handler is not None:
root.addHandler(file_handler)
else:
# Standalone server: own the unconfigured root logger as before.
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
if file_handler is not None:
handlers.append(file_handler)
logging.basicConfig(level=logging.INFO, format="%(message)s", handlers=handlers)
if file_handler_error is not None:
logging.getLogger("mempalace_mcp").warning(
"MEMPALACE_LOG_FILE=%r could not be opened (%s); file logging disabled",
log_file,
file_handler_error,
)
_init_logging()
logger = logging.getLogger("mempalace_mcp")
def _get_result_ids(result) -> list:
"""Return ``get()`` result ids for both typed and dict-like collection results."""
if result is None:
return []
ids = getattr(result, "ids", None)
if ids is not None:
return ids
if isinstance(result, dict):
return result.get("ids") or []
getter = getattr(result, "get", None)
if callable(getter):
return getter("ids") or []
return []
def _parse_args():
parser = argparse.ArgumentParser(description="MemPalace MCP Server")
parser.add_argument(
"--palace",
metavar="PATH",
help="Path to the palace directory (overrides config file and env var)",
)
parser.add_argument(
"--backend",
metavar="NAME",
help="Storage backend to use (default: config/env/detected/chroma)",
)
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default="stdio",
help="Serve MCP over stdio (default) or in-process HTTP",
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="HTTP host to bind when --transport=http (default: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
default=8765,
help="HTTP port to bind when --transport=http (default: 8765)",
)
parser.add_argument(
"--tls-cert",
metavar="PATH",
help="PEM certificate to terminate TLS on the HTTP transport "
"(requires --tls-key; env MEMPALACE_MCP_TLS_CERT)",
)
parser.add_argument(
"--tls-key",
metavar="PATH",
help="PEM private key matching --tls-cert (env MEMPALACE_MCP_TLS_KEY)",
)
parser.add_argument(
"--read-only",
action="store_true",
help="Serve a read-only tool surface: the tools that change state are hidden "
"from tools/list and refused at dispatch (env MEMPALACE_MCP_READ_ONLY)",
)
args, unknown = parser.parse_known_args()
if unknown:
logger.debug("Ignoring unknown args: %s", unknown)
return args
_args = _parse_args()
if _args.palace:
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(_args.palace)
if _args.backend:
backend_name = str(_args.backend).strip().lower()
from .backends import get_backend_class # noqa: E402
get_backend_class(backend_name)
os.environ["MEMPALACE_BACKEND_EXPLICIT"] = backend_name
os.environ["MEMPALACE_BACKEND"] = backend_name
_config = MempalaceConfig()
# Read-only server mode: when on, the tools in _READ_ONLY_REFUSED_TOOLS (defined
# below) are hidden from tools/list and refused at dispatch (-32003). That is a
# wider set than the _MUTATING_TOOLS the peer-writer guard uses. Resolved once at
# startup from --read-only or MEMPALACE_MCP_READ_ONLY. Computed inline (not via
# _truthy_env, defined below) so it is available to the request path regardless
# of import order.
_READ_ONLY = bool(getattr(_args, "read_only", False)) or os.environ.get(
"MEMPALACE_MCP_READ_ONLY", ""
).strip().lower() in {"1", "true", "yes", "on"}
_kg_by_path: dict[str, KnowledgeGraph] = {}
_kg_cache_lock = threading.Lock()
_logstream_by_path: dict[str, Logstream] = {}
_logstream_cache_lock = threading.Lock()
_palace_flag_given: bool = bool(_args.palace)
# MCP server idle auto-exit (#1552). Stale MCP servers from ended Claude
# Code sessions do not self-terminate, accumulating ChromaDB/HNSW file
# handles on Windows. When MEMPALACE_MCP_IDLE_HOURS is set (or defaults
# to 8 h), a background daemon thread exits the process once no request
# has been handled for that long. Set to 0 to disable.
_MCP_IDLE_HOURS_ENV = "MEMPALACE_MCP_IDLE_HOURS"
_MCP_IDLE_HOURS_DEFAULT = 8.0
_last_request_time: float = time.monotonic()
# MCP startup/open SQLite integrity gate (#1818).
#
# The peer-writer guard prevents new concurrent writers, but an MCP server can
# still start against a palace that was already left corrupt by a prior writer
# crash/kill. Run the existing read-only SQLite quick_check once on startup/open
# and fail loudly instead of silently serving a malformed FTS5/HNSW index.
_sqlite_integrity_checked = False
_sqlite_integrity_errors: list[str] = []
_sqlite_integrity_check_error = ""
# Serializes quick_check runs between the async startup preflight thread and
# lazy consumers on the protocol thread (double-checked in
# _ensure_sqlite_integrity_status) so the O(database size) probe never runs
# twice concurrently.
_sqlite_integrity_refresh_lock = threading.Lock()
_SQLITE_INTEGRITY_ERROR_CODE = -32002
_SQLITE_INTEGRITY_ALLOWED_TOOLS = frozenset(
{
"mempalace_status",
"mempalace_reconnect",
# RFC 003: logstream lives in its own logstream.sqlite3 with no
# Chroma/FTS5 dependency, so agent coordination stays available
# even while the main palace index is corrupt and under repair.
"mempalace_event_append",
"mempalace_event_list",
"mempalace_event_wait",
"mempalace_event_ack",
"mempalace_artifact_put",
"mempalace_artifact_get",
"mempalace_patch_submit",
# RFC 004: the estate is observability — logstream + sync state +
# peers.json, no FTS5 dependency (the profile's drawer count
# degrades gracefully). A damaged palace is exactly when mesh
# visibility matters most; caught live on the third replica.
"mempalace_mesh_peers",
}
)
# The startup probe above runs PRAGMA quick_check, which reads every page of
# chroma.sqlite3 and is therefore O(database size). On multi-GB palaces it can
# exceed the MCP client's connection/handshake timeout, so the server never
# finishes starting and the client drops the connection (the peer-writer guard
# and lazy consumers all funnel through _refresh_sqlite_integrity_status). Skip
# the *startup* probe when the database exceeds this size (MB). `mempalace
# repair` still runs the full quick_check via repair.sqlite_integrity_errors
# before any destructive rebuild, so corruption is still caught where it
# matters. Set MEMPALACE_STARTUP_INTEGRITY_MAX_MB=0 to disable the gate and
# always run the startup probe.
_STARTUP_INTEGRITY_MAX_MB_ENV = "MEMPALACE_STARTUP_INTEGRITY_MAX_MB"
_STARTUP_INTEGRITY_MAX_MB_DEFAULT = 512.0
# MCP peer-writer guard (#1818).
#
# The existing per-operation palace lock serializes individual writes, but it
# cannot make another long-lived Chroma PersistentClient forget stale in-memory
# HNSW/FTS state. Hold the same per-palace mine lock for this MCP process
# lifetime. A peer MCP process can still serve read tools, but mutating tools
# refuse before touching Chroma or the knowledge graph.
_MCP_WRITER_LOCK_CM = None
_MCP_WRITER_READ_ONLY = False
_MCP_WRITER_LOCK_FAILED = False
_MCP_WRITER_LOCK_ERROR = ""
_MCP_WRITER_ATEXIT_REGISTERED = False
_MCP_ALLOW_PEER_WRITER_ENV = "MEMPALACE_MCP_ALLOW_PEER_WRITER"
_MUTATING_TOOLS = frozenset(
{
"mempalace_kg_add",
"mempalace_kg_invalidate",
"mempalace_kg_supersede",
"mempalace_create_tunnel",
"mempalace_delete_tunnel",
"mempalace_delete_hallway",
"mempalace_add_drawer",
"mempalace_delete_drawer",
"mempalace_checkpoint",
"mempalace_delete_by_source",
"mempalace_mine",
"mempalace_sync",
"mempalace_update_drawer",
"mempalace_diary_write",
"mempalace_event_append",
"mempalace_event_ack",
"mempalace_artifact_put",
"mempalace_patch_submit",
}
)
# Logstream mutating tools (RFC 003) write only to logstream.sqlite3 — an
# independent WAL database with no Chroma/HNSW in-memory state — so the
# peer-writer lease that protects Chroma does not apply to them. Exempting
# them keeps agent coordination alive while a CLI mine or a peer stdio
# writer holds the palace lock. They remain in _MUTATING_TOOLS so operator
# read-only mode (--read-only / MEMPALACE_MCP_READ_ONLY) still hides and
# refuses them.
_PEER_WRITER_EXEMPT_TOOLS = frozenset(
{
"mempalace_event_append",
"mempalace_event_ack",
"mempalace_artifact_put",
"mempalace_patch_submit",
}
)
# The subset of _MUTATING_TOOLS whose write path reaches the chroma vector
# segment. Deliberately narrower: the knowledge-graph and tunnel/hallway tools
# keep their own sqlite/JSON state and never touch HNSW, so an unusable vector
# index has no say over them.
#
# The distinction earns its keep because a write into a diverged HNSW segment
# does not fail — it blocks inside chromadb's Rust upsert with no timeout of its
# own, for the life of the process, while this server holds the palace mine lock
# and the writer lease. One stuck call becomes a palace-wide outage that a still
# healthy handshake hides.
_VECTOR_WRITE_TOOLS = frozenset(
{
"mempalace_add_drawer",
"mempalace_update_drawer",
"mempalace_delete_drawer",
"mempalace_delete_by_source",
"mempalace_diary_write",
"mempalace_checkpoint",
"mempalace_mine",
"mempalace_sync",
}
)
_DIVERGED_INDEX_ERROR_CODE = -32004
# Read-only mode (#1877) refuses a wider set than the peer-writer guard above.
#
# _MUTATING_TOOLS is the *palace-write* set: _mcp_peer_writer_refusal consults it
# to decide which calls need this process to hold the palace mine lock. A tool
# that never touches Chroma or the knowledge graph has to stay out of that set,
# or a server that lost the lease to a peer would start refusing calls the lease
# has no say over.
#
# Two tools are exactly that shape, and read-only has to name both because it is
# a capability boundary rather than a lock: it exists so a shared server can
# serve recall to a client that must not change server state.
#
# mempalace_hook_settings, given an argument, writes the server's
# ~/.mempalace/config.json through MempalaceConfig.set_hook_setting.
# service.WRITE_TOOLS already classifies it as a write, which the daemon uses
# as an allowlist, so read-only was the odd one out.
#
# mempalace_memories_filed_away unlinks ~/.mempalace/hook_state/last_checkpoint
# on both of its branches. Consuming the file is the contract of the tool, but
# it is still a delete of state that outlives the process, on behalf of a
# client with no write access. (service.classify_tool calls this one "read",
# which is wrong for the same reason.)
#
# mempalace_reconnect is deliberately NOT here even though it is not write-free:
# it clears ChromaBackend._quarantined_paths, so the reopen that follows can let
# quarantine_stale_hnsw rename a segment directory. It is the only way to pick up
# an external writer's changes, and _SQLITE_INTEGRITY_ALLOWED_TOOLS already keeps
# it reachable for recovery, so gating it would strand a read-only server on a
# stale index. This set means "refuse what a client asked to change", not
# "nothing past here touches the disk" -- opening the palace or the knowledge
# graph materialises files on its own, which no name-based gate can express.
_READ_ONLY_REFUSED_TOOLS = _MUTATING_TOOLS | {
"mempalace_hook_settings",
"mempalace_memories_filed_away",
}
def _truthy_env(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _discard_mcp_storage_handles() -> None:
"""Close cached storage handles before changing writer-lease state.
A stdio reader can hold a genuine read-only ``sqlite_exact`` collection
while another process owns the palace. Once this process promotes to
writer, that cached collection must not keep routing the mutating request
through its ``query_only`` connection. The inverse matters for embedded
HTTP: close writable handles before releasing the lifetime lease so no
storage client survives beyond the ownership interval.
Also clears per-process embedder-identity validation for this palace:
a prior read-only open of an empty collection may have cached a "validated"
key without recording identity on disk; promotion must re-run enforcement
so the first writable open still labels drawers with the active model.
"""
global \
_client_cache, \
_collection_cache, \
_collection_cache_backend, \
_collection_cache_palace, \
_collection_open_error, \
_palace_db_inode, \
_palace_db_mtime, \
_metadata_cache, \
_metadata_cache_time
cached_client = _client_cache
try:
from .palace import clear_validated_embedder_identity, get_backend_for_palace
backend = get_backend_for_palace(_config.palace_path)
backend.close_palace(PalaceRef(id=_config.palace_path, local_path=_config.palace_path))
clear_validated_embedder_identity(_config.palace_path)
except Exception:
logger.debug("Failed to close cached backend while changing MCP ownership", exc_info=True)
try:
from .palace import clear_validated_embedder_identity
clear_validated_embedder_identity(getattr(_config, "palace_path", None))
except Exception:
logger.debug(
"Failed to clear embedder-identity cache while changing MCP ownership",
exc_info=True,
)
if cached_client is not None:
try:
close = getattr(cached_client, "close", None)
if callable(close):
close()
except Exception:
logger.debug(
"Failed to close MCP-local client while changing ownership",
exc_info=True,
)
_client_cache = None
_collection_cache = None
_collection_cache_backend = None
_collection_cache_palace = None
_collection_open_error = None
_palace_db_inode = 0
_palace_db_mtime = 0.0
_metadata_cache = None
_metadata_cache_time = 0
def _release_mcp_writer_lock() -> None:
"""Close writable handles and release this process's palace lease."""
global _MCP_WRITER_LOCK_CM, _MCP_WRITER_READ_ONLY
lock_cm = _MCP_WRITER_LOCK_CM
if lock_cm is None:
return
try:
_discard_mcp_storage_handles()
finally:
# Clear first so the atexit callback and embedded hosts can call this
# repeatedly without exiting the same context manager twice.
_MCP_WRITER_LOCK_CM = None
_MCP_WRITER_READ_ONLY = False
lock_cm.__exit__(None, None, None)
def _acquire_mcp_writer_lock() -> tuple[bool, str]:
"""Acquire this process's per-palace MCP writer lease.
Returns (True, "") when this process may write. Returns (False, reason)
when another live writer already owns the per-palace lease.
Self-healing: a server that came up read-only (a peer held the lease at
startup) RE-ATTEMPTS the non-blocking flock on every subsequent call.
``_mcp_peer_writer_refusal`` invokes this on each mutating tool, so once
the original holder exits — the OS releases its flock on process death —
the next mutating call transparently promotes this server to writer, with
no restart. The flock is arbitrated by the kernel (LOCK_NB), so two servers
can never both win the retry. ``_MCP_WRITER_READ_ONLY`` and
``_MCP_WRITER_LOCK_FAILED`` are now only status flags for the last attempt;
neither short-circuits a later retry. Peer ownership and transient setup
failures can both be corrected without restarting the MCP host.
"""
global _MCP_WRITER_LOCK_CM, _MCP_WRITER_READ_ONLY, _MCP_WRITER_LOCK_FAILED
global _MCP_WRITER_LOCK_ERROR, _MCP_WRITER_ATEXIT_REGISTERED
if _MCP_WRITER_LOCK_CM is not None:
return True, ""
# Deliberately no sticky failure short-circuit here. A peer can exit, a
# backend mismatch can be corrected, and lock-directory permissions can be
# repaired while this long-lived stdio host remains alive. Each mutating
# request therefore gets a fresh ownership attempt.
try:
from .palace import (
MineAlreadyRunning,
backend_requires_single_writer,
mine_palace_lock,
resolve_backend_name,
)
backend_name = resolve_backend_name(_config.palace_path)
if _truthy_env(_MCP_ALLOW_PEER_WRITER_ENV):
if not backend_requires_single_writer(backend_name):
return True, ""
logger.warning(
"%s cannot bypass the single-writer requirement for local backend %r",
_MCP_ALLOW_PEER_WRITER_ENV,
backend_name,
)
lock_cm = mine_palace_lock(_config.palace_path)
lock_cm.__enter__()
except MineAlreadyRunning as exc:
_MCP_WRITER_READ_ONLY = True
_MCP_WRITER_LOCK_ERROR = (
"another mempalace writer already holds the palace lock for "
f"{_config.palace_path!r}: {exc}"
)
return False, _MCP_WRITER_LOCK_ERROR
except Exception as exc:
_MCP_WRITER_LOCK_FAILED = True
_MCP_WRITER_LOCK_ERROR = (
"could not acquire MCP peer-writer lock for "
f"{_config.palace_path!r}: {exc!r}; refusing this mutating tool "
"because peer-writer protection could not be established; a later "
"mutating request will retry ownership"
)
logger.error(_MCP_WRITER_LOCK_ERROR)
return False, _MCP_WRITER_LOCK_ERROR
_MCP_WRITER_LOCK_CM = lock_cm
import atexit
if not _MCP_WRITER_ATEXIT_REGISTERED:
atexit.register(_release_mcp_writer_lock)
_MCP_WRITER_ATEXIT_REGISTERED = True
# Reads performed before promotion may have cached a query-only SQLite
# collection. Drop it while ownership is held so the pending mutating
# request reopens a writable handle rather than failing on query_only.
_discard_mcp_storage_handles()
_MCP_WRITER_READ_ONLY = False
_MCP_WRITER_LOCK_FAILED = False
_MCP_WRITER_LOCK_ERROR = ""
return True, ""
def _mcp_peer_writer_refusal(req_id, tool_name: str):
if tool_name not in _MUTATING_TOOLS or tool_name in _PEER_WRITER_EXEMPT_TOOLS:
return None
ok, reason = _acquire_mcp_writer_lock()
if ok:
return None
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32001,
"message": "Peer MCP writer active; this server is read-only for mutating tools",
"data": {
"tool": tool_name,
"palace": _config.palace_path,
"reason": reason,
"override_env": _MCP_ALLOW_PEER_WRITER_ENV,
},
},
}
def _startup_integrity_size_limit_bytes() -> int:
"""Byte size above which the startup SQLite quick_check is skipped.
Returns 0 when the gate is disabled (``MEMPALACE_STARTUP_INTEGRITY_MAX_MB``
set to 0, a non-positive number, or an unparseable value), meaning the
startup probe always runs.
"""
raw = os.environ.get(_STARTUP_INTEGRITY_MAX_MB_ENV, "").strip()
if not raw:
mb = _STARTUP_INTEGRITY_MAX_MB_DEFAULT
else:
try:
mb = float(raw)
except ValueError:
logger.warning(
"Invalid %s=%r; using default %.0f MB",
_STARTUP_INTEGRITY_MAX_MB_ENV,
raw,
_STARTUP_INTEGRITY_MAX_MB_DEFAULT,
)
mb = _STARTUP_INTEGRITY_MAX_MB_DEFAULT
if mb <= 0:
return 0
return int(mb * 1024 * 1024)
def _refresh_sqlite_integrity_status() -> None:
"""Refresh the MCP startup SQLite/FTS5 integrity gate.
Uses repair.sqlite_integrity_errors(), which is read-only and already backs
repair preflight. A failure here is treated as an integrity failure so the
server does not proceed silently after a malformed FTS5 index or other
SQLite-layer corruption (#1818).
"""
with _sqlite_integrity_refresh_lock:
_refresh_sqlite_integrity_status_locked()
def _refresh_sqlite_integrity_status_locked() -> None:
# Probe body; callers must hold _sqlite_integrity_refresh_lock.
global _sqlite_integrity_checked
global _sqlite_integrity_errors
global _sqlite_integrity_check_error
if not _config.palace_path or not _is_chroma_backend():
_sqlite_integrity_checked = True
_sqlite_integrity_errors = []
_sqlite_integrity_check_error = ""
return
max_bytes = _startup_integrity_size_limit_bytes()
if max_bytes > 0:
sqlite_path = os.path.join(_config.palace_path, "chroma.sqlite3")
try:
db_bytes = os.path.getsize(sqlite_path)
except OSError:
db_bytes = 0
if db_bytes > max_bytes:
_sqlite_integrity_checked = True
_sqlite_integrity_errors = []
_sqlite_integrity_check_error = ""
logger.warning(
"SQLite startup integrity check skipped: %s is %.0f MB "
"(> %.0f MB limit); PRAGMA quick_check would block MCP "
"startup. Run `mempalace repair` for a full check, or set "
"%s (MB; 0 disables the limit).",
sqlite_path,
db_bytes / (1024 * 1024),
max_bytes / (1024 * 1024),
_STARTUP_INTEGRITY_MAX_MB_ENV,
)
return
try:
from .repair import sqlite_integrity_errors
errors = sqlite_integrity_errors(_config.palace_path)
except Exception as exc:
_sqlite_integrity_check_error = (
f"sqlite integrity probe failed: {type(exc).__name__}: {exc}"
)
_sqlite_integrity_errors = [_sqlite_integrity_check_error]
else:
_sqlite_integrity_errors = [str(error) for error in errors if str(error)]
_sqlite_integrity_check_error = ""
_sqlite_integrity_checked = True
if _sqlite_integrity_errors:
logger.error(
"SQLite integrity check failed for palace=%s: %s",
_config.palace_path,
"; ".join(_sqlite_integrity_errors[:3]),
)
def _ensure_sqlite_integrity_status() -> None:
if _sqlite_integrity_checked:
return
with _sqlite_integrity_refresh_lock:
# Double-checked: the startup preflight thread may have finished the
# probe while this caller waited on the lock — don't pay the
# O(database size) quick_check twice.
if not _sqlite_integrity_checked:
_refresh_sqlite_integrity_status_locked()
def _sqlite_integrity_payload() -> dict:
_ensure_sqlite_integrity_status()
# The integrity gate only knows how to check chroma.sqlite3, and
# _refresh_sqlite_integrity_status short-circuits for non-chroma backends,
# so on a non-chroma backend no quick_check runs. Reporting checked/ok true
# would imply a verification that never happened and reference a
# chroma.sqlite3 the active backend does not use (#1931). Recorded errors
# only ever come from the chroma path, so surface them regardless of the
# backend lookup (which may itself fail); only the clean case is
# reclassified as not-applicable.
if not _sqlite_integrity_errors:
try:
backend_name = _selected_backend_name()
except Exception:
logger.debug("backend resolution failed for integrity payload", exc_info=True)
backend_name = ""
if backend_name != "chroma":
return {
"checked": False,
"ok": None,
"palace": _config.palace_path or "",
"sqlite_path": "",
"error_count": 0,
"errors": [],
"reason": (
"chroma.sqlite3 integrity check does not run for backend "
f"{backend_name or 'unknown'!r}"
),
}
payload = {
"checked": _sqlite_integrity_checked,
"ok": not _sqlite_integrity_errors,
"palace": _config.palace_path,
"sqlite_path": os.path.join(_config.palace_path, "chroma.sqlite3")
if _config.palace_path
else "",
"error_count": len(_sqlite_integrity_errors),
"errors": _sqlite_integrity_errors[:10],
}
if len(_sqlite_integrity_errors) > 10:
payload["truncated"] = len(_sqlite_integrity_errors) - 10
if _sqlite_integrity_check_error:
payload["check_error"] = _sqlite_integrity_check_error
return payload
def _mcp_sqlite_integrity_refusal(req_id, tool_name: str):
if tool_name in _SQLITE_INTEGRITY_ALLOWED_TOOLS:
return None
_ensure_sqlite_integrity_status()
if not _sqlite_integrity_errors:
return None
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": _SQLITE_INTEGRITY_ERROR_CODE,
"message": (
"Palace SQLite integrity check failed; refusing tool call "
"until the palace is repaired"
),
"data": {
"tool": tool_name,
"palace": _config.palace_path or "",
"sqlite_path": (
os.path.join(_config.palace_path, "chroma.sqlite3")
if _config.palace_path
else ""
),
"errors": _sqlite_integrity_errors[:10],
"error_count": len(_sqlite_integrity_errors),
"hint": (
"Stop all MemPalace MCP clients/writers, back up the palace, "
"repair the SQLite/FTS5 corruption offline, then run "
"mempalace_reconnect or restart the MCP server."
),
},
},
}
def _mcp_idle_timeout_secs() -> float:
"""Return the configured MCP idle timeout in seconds (0 = disabled)."""
raw = os.environ.get(_MCP_IDLE_HOURS_ENV, "")
if raw:
try:
hours = float(raw)
return max(0.0, hours) * 3600
except ValueError:
return 0.0
return _MCP_IDLE_HOURS_DEFAULT * 3600
def _resolve_kg_path() -> str:
if _palace_flag_given:
return os.path.join(_config.palace_path, "knowledge_graph.sqlite3")
return DEFAULT_KG_PATH
def _canonicalize_kg_path(path: str) -> str:
"""Canonicalize a KG cache key so aliases collapse onto one entry.
``realpath`` resolves symlinks: two tenants pointing at the same
SQLite file via different layouts (``/srv/A`` and
``/srv/link-to-A``) hit a single cached ``KnowledgeGraph`` rather
than opening duplicate connections. ``normcase`` normalizes Windows
drive-letter casing (``C:\\palace`` vs ``c:\\palace``) and
path-separator style; on POSIX it returns the input unchanged.
"""
return os.path.normcase(os.path.realpath(path))
def _get_kg(canonical_path=None) -> KnowledgeGraph:
"""Return the cached ``KnowledgeGraph`` for the resolved palace.
When ``canonical_path`` is ``None`` (default), the path is resolved
from module state and canonicalized. Callers like :func:`_call_kg`
that have already captured a canonical key before entering a retry
loop should pass it through here so the dict insertion uses the same
key the caller will later use for eviction. Recomputing the key
inside this function would let ``MEMPALACE_PALACE_PATH`` rotation,
a symlink remap, or a mount remap between the captured value and
this call drift the insert and evict keys apart, stranding a closed
handle under one key while the lookup probes another.
"""
path = (
canonical_path if canonical_path is not None else _canonicalize_kg_path(_resolve_kg_path())
)
kg = _kg_by_path.get(path)
if kg is not None:
return kg
with _kg_cache_lock:
kg = _kg_by_path.get(path)
if kg is None:
kg = KnowledgeGraph(db_path=path)
_kg_by_path[path] = kg
return kg
def _call_kg(op):
"""Run ``op(kg)`` against the cached KG with one-shot retry on close.
Race we're guarding against: a handler grabs ``kg = _get_kg()`` and is
about to call ``kg.add_triple(...)`` when ``tool_reconnect`` fires on
another thread, drains ``_kg_by_path``, and closes the underlying
sqlite3.Connection. The handler's call then raises
``sqlite3.ProgrammingError: Cannot operate on a closed database`` and
bubbles up as a -32000 to the MCP client even though the user just
asked for a reconnect.
Catch that single class of error, evict the stale entry from the
cache (only if it still points at the closed instance — another
thread may have already replaced it), and try once more with a fresh
KG. Beyond one retry give up: a second close means we're losing a
sustained race we won't win in this loop, and a hung loop is worse
than a clear failure surface.
The canonical path is captured once at the top and threaded through
every ``_get_kg`` call plus the eviction lookup. Doing canonicalize
only here means an ``OSError`` from ``realpath`` (transient Windows
junction loss, broken mount) surfaces cleanly before any handler
runs instead of masking a ``sqlite3.ProgrammingError`` mid-retry.
Passing the captured key through to ``_get_kg`` also locks the
insert key to the evict key even if FS or env state mutates between
attempts, preventing a closed handle from leaking under a stale
key the lookup no longer matches.
"""
path = _canonicalize_kg_path(_resolve_kg_path())
for attempt in range(2):
kg = _get_kg(path)
try:
return op(kg)
except sqlite3.ProgrammingError:
if attempt == 0:
with _kg_cache_lock: