mempalace-code 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MemPalace MCP Server — read/write palace access for Claude Code
4
+ ================================================================
5
+ Install: claude mcp add mempalace -- python -m mempalace.mcp_server
6
+
7
+ Tools (read):
8
+ mempalace_status — total drawers, wing/room breakdown
9
+ mempalace_list_wings — all wings with drawer counts
10
+ mempalace_list_rooms — rooms within a wing
11
+ mempalace_get_taxonomy — full wing → room → count tree
12
+ mempalace_search — semantic search, optional wing/room filter
13
+ mempalace_code_search — code-optimized search with symbol/language/file filters
14
+ mempalace_check_duplicate — check if content already exists before filing
15
+
16
+ Tools (write):
17
+ mempalace_add_drawer — file verbatim content into a wing/room
18
+ mempalace_delete_drawer — remove a drawer by ID
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ import json
24
+ import logging
25
+ import hashlib
26
+ from datetime import datetime
27
+ from typing import Optional
28
+
29
+ from .config import MempalaceConfig
30
+ from .version import __version__
31
+ from .searcher import code_search, search_memories
32
+ from .palace_graph import traverse, find_tunnels, graph_stats
33
+ from .storage import open_store, DrawerStore, LanceStore
34
+
35
+ from .knowledge_graph import KnowledgeGraph
36
+
37
+ _kg = KnowledgeGraph()
38
+
39
+ logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
40
+ logger = logging.getLogger("mempalace_mcp")
41
+
42
+ _config = MempalaceConfig()
43
+
44
+ # Singleton store — opened once, reused across all tool calls
45
+ _store: Optional[DrawerStore] = None
46
+
47
+
48
+ def _get_store(create=False) -> Optional[DrawerStore]:
49
+ """Return the drawer store, or None on failure."""
50
+ global _store
51
+ if _store is not None:
52
+ return _store
53
+ try:
54
+ new_store = open_store(_config.palace_path, create=create)
55
+ # Don't cache a LanceStore whose backing table is missing: a subsequent
56
+ # call with create=True would get the stub and fail. Keep retrying
57
+ # until the palace is actually initialised.
58
+ if not (isinstance(new_store, LanceStore) and new_store._table is None):
59
+ _store = new_store
60
+ return new_store
61
+ except Exception:
62
+ return None
63
+
64
+
65
+ def _no_palace():
66
+ return {
67
+ "error": "No palace found",
68
+ "hint": "Run: mempalace init <dir> && mempalace mine <dir>",
69
+ }
70
+
71
+
72
+ # ==================== READ TOOLS ====================
73
+
74
+
75
+ def tool_status():
76
+ col = _get_store()
77
+ if not col:
78
+ return _no_palace()
79
+ count = col.count()
80
+ wings: dict = {}
81
+ rooms: dict = {}
82
+ try:
83
+ taxonomy = col.count_by_pair("wing", "room")
84
+ for w, room_counts in taxonomy.items():
85
+ wings[w] = sum(room_counts.values())
86
+ for r, c in room_counts.items():
87
+ rooms[r] = rooms.get(r, 0) + c
88
+ except Exception:
89
+ pass
90
+ result = {
91
+ "total_drawers": count,
92
+ "wings": wings,
93
+ "rooms": rooms,
94
+ "palace_path": _config.palace_path,
95
+ }
96
+ if os.environ.get("MEMPALACE_AAAK") == "1":
97
+ result["protocol"] = PALACE_PROTOCOL
98
+ result["aaak_dialect"] = AAAK_SPEC
99
+ return result
100
+
101
+
102
+ # ── AAAK Dialect Spec ─────────────────────────────────────────────────────────
103
+ # Included in status response only when MEMPALACE_AAAK=1 (opt-in).
104
+ # Not exposed as an MCP tool in v1.0 — kept dormant.
105
+
106
+ PALACE_PROTOCOL = """IMPORTANT — MemPalace Memory Protocol:
107
+ 1. ON WAKE-UP: Call mempalace_status to load palace overview + AAAK spec.
108
+ 2. BEFORE RESPONDING about any person, project, or past event: call mempalace_kg_query or mempalace_search FIRST. Never guess — verify.
109
+ 3. IF UNSURE about a fact (name, gender, age, relationship): say "let me check" and query the palace. Wrong is worse than slow.
110
+ 4. AFTER EACH SESSION: call mempalace_diary_write to record what happened, what you learned, what matters.
111
+ 5. WHEN FACTS CHANGE: call mempalace_kg_invalidate on the old fact, mempalace_kg_add for the new one.
112
+
113
+ This protocol ensures the AI KNOWS before it speaks. Storage is not memory — but storage + this protocol = memory."""
114
+
115
+ AAAK_SPEC = """AAAK is a compressed memory dialect that MemPalace uses for efficient storage.
116
+ It is designed to be readable by both humans and LLMs without decoding.
117
+
118
+ FORMAT:
119
+ ENTITIES: 3-letter uppercase codes. ALC=Alice, JOR=Jordan, RIL=Riley, MAX=Max, BEN=Ben.
120
+ EMOTIONS: *action markers* before/during text. *warm*=joy, *fierce*=determined, *raw*=vulnerable, *bloom*=tenderness.
121
+ STRUCTURE: Pipe-separated fields. FAM: family | PROJ: projects | ⚠: warnings/reminders.
122
+ DATES: ISO format (2026-03-31). COUNTS: Nx = N mentions (e.g., 570x).
123
+ IMPORTANCE: ★ to ★★★★★ (1-5 scale).
124
+ HALLS: hall_facts, hall_events, hall_discoveries, hall_preferences, hall_advice.
125
+ WINGS: wing_user, wing_agent, wing_team, wing_code, wing_myproject, wing_hardware, wing_ue5, wing_ai_research.
126
+ ROOMS: Hyphenated slugs representing named ideas (e.g., chromadb-setup, gpu-pricing).
127
+
128
+ EXAMPLE:
129
+ FAM: ALC→♡JOR | 2D(kids): RIL(18,sports) MAX(11,chess+swimming) | BEN(contributor)
130
+
131
+ Read AAAK naturally — expand codes mentally, treat *markers* as emotional context.
132
+ When WRITING AAAK: use entity codes, mark emotions, keep structure tight."""
133
+
134
+
135
+ def tool_list_wings():
136
+ col = _get_store()
137
+ if not col:
138
+ return _no_palace()
139
+ wings: dict = {}
140
+ try:
141
+ wings = col.count_by("wing")
142
+ except Exception:
143
+ pass
144
+ return {"wings": wings}
145
+
146
+
147
+ def tool_list_rooms(wing: str = None):
148
+ col = _get_store()
149
+ if not col:
150
+ return _no_palace()
151
+ rooms: dict = {}
152
+ try:
153
+ taxonomy = col.count_by_pair("wing", "room")
154
+ if wing:
155
+ rooms = taxonomy.get(wing, {})
156
+ else:
157
+ for wing_rooms in taxonomy.values():
158
+ for r, c in wing_rooms.items():
159
+ rooms[r] = rooms.get(r, 0) + c
160
+ except Exception:
161
+ pass
162
+ return {"wing": wing or "all", "rooms": rooms}
163
+
164
+
165
+ def tool_get_taxonomy():
166
+ col = _get_store()
167
+ if not col:
168
+ return _no_palace()
169
+ taxonomy: dict = {}
170
+ try:
171
+ taxonomy = col.count_by_pair("wing", "room")
172
+ except Exception:
173
+ pass
174
+ return {"taxonomy": taxonomy}
175
+
176
+
177
+ def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None):
178
+ return search_memories(
179
+ query,
180
+ palace_path=_config.palace_path,
181
+ wing=wing,
182
+ room=room,
183
+ n_results=limit,
184
+ )
185
+
186
+
187
+ def tool_code_search(
188
+ query: str,
189
+ language: str = None,
190
+ symbol_name: str = None,
191
+ symbol_type: str = None,
192
+ file_glob: str = None,
193
+ wing: str = None,
194
+ n_results: int = 10,
195
+ ):
196
+ return code_search(
197
+ palace_path=_config.palace_path,
198
+ query=query,
199
+ language=language,
200
+ symbol_name=symbol_name,
201
+ symbol_type=symbol_type,
202
+ file_glob=file_glob,
203
+ wing=wing,
204
+ n_results=n_results,
205
+ )
206
+
207
+
208
+ def tool_check_duplicate(content: str, threshold: float = 0.9):
209
+ col = _get_store()
210
+ if not col:
211
+ return _no_palace()
212
+ try:
213
+ results = col.query(
214
+ query_texts=[content],
215
+ n_results=5,
216
+ include=["metadatas", "documents", "distances"],
217
+ )
218
+ duplicates = []
219
+ if results["ids"] and results["ids"][0]:
220
+ for i, drawer_id in enumerate(results["ids"][0]):
221
+ dist = results["distances"][0][i]
222
+ similarity = round(1 - dist, 3)
223
+ if similarity >= threshold:
224
+ meta = results["metadatas"][0][i]
225
+ doc = results["documents"][0][i]
226
+ duplicates.append(
227
+ {
228
+ "id": drawer_id,
229
+ "wing": meta.get("wing", "?"),
230
+ "room": meta.get("room", "?"),
231
+ "similarity": similarity,
232
+ "content": doc[:200] + "..." if len(doc) > 200 else doc,
233
+ }
234
+ )
235
+ return {
236
+ "is_duplicate": len(duplicates) > 0,
237
+ "matches": duplicates,
238
+ }
239
+ except Exception as e:
240
+ return {"error": str(e)}
241
+
242
+
243
+ def tool_get_aaak_spec():
244
+ """Return the AAAK dialect specification."""
245
+ return {"aaak_spec": AAAK_SPEC}
246
+
247
+
248
+ def tool_traverse_graph(start_room: str, max_hops: int = 2):
249
+ """Walk the palace graph from a room. Find connected ideas across wings."""
250
+ col = _get_store()
251
+ if not col:
252
+ return _no_palace()
253
+ return traverse(start_room, col=col, max_hops=max_hops)
254
+
255
+
256
+ def tool_find_tunnels(wing_a: str = None, wing_b: str = None):
257
+ """Find rooms that bridge two wings — the hallways connecting domains."""
258
+ col = _get_store()
259
+ if not col:
260
+ return _no_palace()
261
+ return find_tunnels(wing_a, wing_b, col=col)
262
+
263
+
264
+ def tool_graph_stats():
265
+ """Palace graph overview: nodes, tunnels, edges, connectivity."""
266
+ col = _get_store()
267
+ if not col:
268
+ return _no_palace()
269
+ return graph_stats(col=col)
270
+
271
+
272
+ # ==================== WRITE TOOLS ====================
273
+
274
+
275
+ def tool_add_drawer(
276
+ wing: str, room: str, content: str, source_file: str = None, added_by: str = "mcp"
277
+ ):
278
+ """File verbatim content into a wing/room. Checks for duplicates first."""
279
+ col = _get_store(create=True)
280
+ if not col:
281
+ return _no_palace()
282
+
283
+ # Duplicate check
284
+ dup = tool_check_duplicate(content, threshold=0.9)
285
+ if dup.get("is_duplicate"):
286
+ return {
287
+ "success": False,
288
+ "reason": "duplicate",
289
+ "matches": dup["matches"],
290
+ }
291
+
292
+ drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((content[:100] + datetime.now().isoformat()).encode()).hexdigest()[:16]}"
293
+
294
+ try:
295
+ col.add(
296
+ ids=[drawer_id],
297
+ documents=[content],
298
+ metadatas=[
299
+ {
300
+ "wing": wing,
301
+ "room": room,
302
+ "source_file": source_file or "",
303
+ "chunk_index": 0,
304
+ "added_by": added_by,
305
+ "filed_at": datetime.now().isoformat(),
306
+ "extractor_version": __version__,
307
+ "chunker_strategy": "manual_v1",
308
+ }
309
+ ],
310
+ )
311
+ logger.info(f"Filed drawer: {drawer_id} → {wing}/{room}")
312
+ return {"success": True, "drawer_id": drawer_id, "wing": wing, "room": room}
313
+ except Exception as e:
314
+ return {"success": False, "error": str(e)}
315
+
316
+
317
+ def tool_delete_drawer(drawer_id: str):
318
+ """Delete a single drawer by ID."""
319
+ col = _get_store()
320
+ if not col:
321
+ return _no_palace()
322
+ existing = col.get(ids=[drawer_id])
323
+ if not existing["ids"]:
324
+ return {"success": False, "error": f"Drawer not found: {drawer_id}"}
325
+ try:
326
+ col.delete(ids=[drawer_id])
327
+ logger.info(f"Deleted drawer: {drawer_id}")
328
+ return {"success": True, "drawer_id": drawer_id}
329
+ except Exception as e:
330
+ return {"success": False, "error": str(e)}
331
+
332
+
333
+ def tool_delete_wing(wing: str):
334
+ """Delete all drawers in a wing. Irreversible."""
335
+ col = _get_store()
336
+ if not col:
337
+ return _no_palace()
338
+ existing = col.get(where={"wing": wing}, limit=1)
339
+ if not existing["ids"]:
340
+ return {"success": False, "error": f"Wing not found: {wing}"}
341
+ try:
342
+ deleted_count = col.delete_wing(wing)
343
+ logger.info(f"Deleted wing: {wing} ({deleted_count} drawers)")
344
+ return {"success": True, "wing": wing, "deleted_count": deleted_count}
345
+ except Exception as e:
346
+ return {"success": False, "error": str(e)}
347
+
348
+
349
+ # ==================== KNOWLEDGE GRAPH ====================
350
+
351
+
352
+ def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"):
353
+ """Query the knowledge graph for an entity's relationships."""
354
+ results = _kg.query_entity(entity, as_of=as_of, direction=direction)
355
+ return {"entity": entity, "as_of": as_of, "facts": results, "count": len(results)}
356
+
357
+
358
+ def tool_kg_add(
359
+ subject: str, predicate: str, object: str, valid_from: str = None, source_closet: str = None
360
+ ):
361
+ """Add a relationship to the knowledge graph."""
362
+ triple_id = _kg.add_triple(
363
+ subject, predicate, object, valid_from=valid_from, source_closet=source_closet
364
+ )
365
+ return {"success": True, "triple_id": triple_id, "fact": f"{subject} → {predicate} → {object}"}
366
+
367
+
368
+ def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = None):
369
+ """Mark a fact as no longer true (set end date)."""
370
+ _kg.invalidate(subject, predicate, object, ended=ended)
371
+ return {
372
+ "success": True,
373
+ "fact": f"{subject} → {predicate} → {object}",
374
+ "ended": ended or "today",
375
+ }
376
+
377
+
378
+ def tool_kg_timeline(entity: str = None):
379
+ """Get chronological timeline of facts, optionally for one entity."""
380
+ results = _kg.timeline(entity)
381
+ return {"entity": entity or "all", "timeline": results, "count": len(results)}
382
+
383
+
384
+ def tool_kg_stats():
385
+ """Knowledge graph overview: entities, triples, relationship types."""
386
+ return _kg.stats()
387
+
388
+
389
+ # ==================== AGENT DIARY ====================
390
+
391
+
392
+ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
393
+ """
394
+ Write a diary entry for this agent. Each agent gets its own wing
395
+ with a diary room. Entries are timestamped and accumulate over time.
396
+
397
+ This is the agent's personal journal — observations, thoughts,
398
+ what it worked on, what it noticed, what it thinks matters.
399
+ """
400
+ wing = f"wing_{agent_name.lower().replace(' ', '_')}"
401
+ room = "diary"
402
+ col = _get_store(create=True)
403
+ if not col:
404
+ return _no_palace()
405
+
406
+ now = datetime.now()
407
+ entry_id = f"diary_{wing}_{now.strftime('%Y%m%d_%H%M%S')}_{hashlib.md5(entry[:50].encode()).hexdigest()[:8]}"
408
+
409
+ try:
410
+ col.add(
411
+ ids=[entry_id],
412
+ documents=[entry],
413
+ metadatas=[
414
+ {
415
+ "wing": wing,
416
+ "room": room,
417
+ "hall": "hall_diary",
418
+ "topic": topic,
419
+ "type": "diary_entry",
420
+ "agent": agent_name,
421
+ "filed_at": now.isoformat(),
422
+ "date": now.strftime("%Y-%m-%d"),
423
+ "extractor_version": __version__,
424
+ "chunker_strategy": "diary_v1",
425
+ }
426
+ ],
427
+ )
428
+ logger.info(f"Diary entry: {entry_id} → {wing}/diary/{topic}")
429
+ return {
430
+ "success": True,
431
+ "entry_id": entry_id,
432
+ "agent": agent_name,
433
+ "topic": topic,
434
+ "timestamp": now.isoformat(),
435
+ }
436
+ except Exception as e:
437
+ return {"success": False, "error": str(e)}
438
+
439
+
440
+ def tool_diary_read(agent_name: str, last_n: int = 10):
441
+ """
442
+ Read an agent's recent diary entries. Returns the last N entries
443
+ in chronological order — the agent's personal journal.
444
+ """
445
+ wing = f"wing_{agent_name.lower().replace(' ', '_')}"
446
+ col = _get_store()
447
+ if not col:
448
+ return _no_palace()
449
+
450
+ try:
451
+ results = col.get(
452
+ where={"$and": [{"wing": wing}, {"room": "diary"}]},
453
+ include=["documents", "metadatas"],
454
+ limit=col.count(),
455
+ )
456
+
457
+ if not results["ids"]:
458
+ return {"agent": agent_name, "entries": [], "message": "No diary entries yet."}
459
+
460
+ # Combine and sort by timestamp
461
+ entries = []
462
+ for doc, meta in zip(results["documents"], results["metadatas"]):
463
+ entries.append(
464
+ {
465
+ "date": meta.get("date", ""),
466
+ "timestamp": meta.get("filed_at", ""),
467
+ "topic": meta.get("topic", ""),
468
+ "content": doc,
469
+ }
470
+ )
471
+
472
+ entries.sort(key=lambda x: x["timestamp"], reverse=True)
473
+ entries = entries[:last_n]
474
+
475
+ return {
476
+ "agent": agent_name,
477
+ "entries": entries,
478
+ "total": len(results["ids"]),
479
+ "showing": len(entries),
480
+ }
481
+ except Exception as e:
482
+ return {"error": str(e)}
483
+
484
+
485
+ # ==================== MCP PROTOCOL ====================
486
+
487
+ TOOLS = {
488
+ "mempalace_status": {
489
+ "description": "Palace overview — total drawers, wing and room counts",
490
+ "input_schema": {"type": "object", "properties": {}},
491
+ "handler": tool_status,
492
+ },
493
+ "mempalace_list_wings": {
494
+ "description": "List all wings with drawer counts",
495
+ "input_schema": {"type": "object", "properties": {}},
496
+ "handler": tool_list_wings,
497
+ },
498
+ "mempalace_list_rooms": {
499
+ "description": "List rooms within a wing (or all rooms if no wing given)",
500
+ "input_schema": {
501
+ "type": "object",
502
+ "properties": {
503
+ "wing": {"type": "string", "description": "Wing to list rooms for (optional)"},
504
+ },
505
+ },
506
+ "handler": tool_list_rooms,
507
+ },
508
+ "mempalace_get_taxonomy": {
509
+ "description": "Full taxonomy: wing → room → drawer count",
510
+ "input_schema": {"type": "object", "properties": {}},
511
+ "handler": tool_get_taxonomy,
512
+ },
513
+ "mempalace_kg_query": {
514
+ "description": "Query the knowledge graph for an entity's relationships. Returns typed facts with temporal validity. E.g. 'Max' → child_of Alice, loves chess, does swimming. Filter by date with as_of to see what was true at a point in time.",
515
+ "input_schema": {
516
+ "type": "object",
517
+ "properties": {
518
+ "entity": {
519
+ "type": "string",
520
+ "description": "Entity to query (e.g. 'Max', 'MyProject', 'Alice')",
521
+ },
522
+ "as_of": {
523
+ "type": "string",
524
+ "description": "Date filter — only facts valid at this date (YYYY-MM-DD, optional)",
525
+ },
526
+ "direction": {
527
+ "type": "string",
528
+ "description": "outgoing (entity→?), incoming (?→entity), or both (default: both)",
529
+ },
530
+ },
531
+ "required": ["entity"],
532
+ },
533
+ "handler": tool_kg_query,
534
+ },
535
+ "mempalace_kg_add": {
536
+ "description": "Add a fact to the knowledge graph. Subject → predicate → object with optional time window. E.g. ('Max', 'started_school', 'Year 7', valid_from='2026-09-01').",
537
+ "input_schema": {
538
+ "type": "object",
539
+ "properties": {
540
+ "subject": {"type": "string", "description": "The entity doing/being something"},
541
+ "predicate": {
542
+ "type": "string",
543
+ "description": "The relationship type (e.g. 'loves', 'works_on', 'daughter_of')",
544
+ },
545
+ "object": {"type": "string", "description": "The entity being connected to"},
546
+ "valid_from": {
547
+ "type": "string",
548
+ "description": "When this became true (YYYY-MM-DD, optional)",
549
+ },
550
+ "source_closet": {
551
+ "type": "string",
552
+ "description": "Closet ID where this fact appears (optional)",
553
+ },
554
+ },
555
+ "required": ["subject", "predicate", "object"],
556
+ },
557
+ "handler": tool_kg_add,
558
+ },
559
+ "mempalace_kg_invalidate": {
560
+ "description": "Mark a fact as no longer true. E.g. ankle injury resolved, job ended, moved house.",
561
+ "input_schema": {
562
+ "type": "object",
563
+ "properties": {
564
+ "subject": {"type": "string", "description": "Entity"},
565
+ "predicate": {"type": "string", "description": "Relationship"},
566
+ "object": {"type": "string", "description": "Connected entity"},
567
+ "ended": {
568
+ "type": "string",
569
+ "description": "When it stopped being true (YYYY-MM-DD, default: today)",
570
+ },
571
+ },
572
+ "required": ["subject", "predicate", "object"],
573
+ },
574
+ "handler": tool_kg_invalidate,
575
+ },
576
+ "mempalace_kg_timeline": {
577
+ "description": "Chronological timeline of facts. Shows the story of an entity (or everything) in order.",
578
+ "input_schema": {
579
+ "type": "object",
580
+ "properties": {
581
+ "entity": {
582
+ "type": "string",
583
+ "description": "Entity to get timeline for (optional — omit for full timeline)",
584
+ },
585
+ },
586
+ },
587
+ "handler": tool_kg_timeline,
588
+ },
589
+ "mempalace_kg_stats": {
590
+ "description": "Knowledge graph overview: entities, triples, current vs expired facts, relationship types.",
591
+ "input_schema": {"type": "object", "properties": {}},
592
+ "handler": tool_kg_stats,
593
+ },
594
+ "mempalace_traverse": {
595
+ "description": "Walk the palace graph from a room. Shows connected ideas across wings — the tunnels. Like following a thread through the palace: start at 'chromadb-setup' in wing_code, discover it connects to wing_myproject (planning) and wing_user (feelings about it).",
596
+ "input_schema": {
597
+ "type": "object",
598
+ "properties": {
599
+ "start_room": {
600
+ "type": "string",
601
+ "description": "Room to start from (e.g. 'chromadb-setup', 'riley-school')",
602
+ },
603
+ "max_hops": {
604
+ "type": "integer",
605
+ "description": "How many connections to follow (default: 2)",
606
+ },
607
+ },
608
+ "required": ["start_room"],
609
+ },
610
+ "handler": tool_traverse_graph,
611
+ },
612
+ "mempalace_find_tunnels": {
613
+ "description": "Find rooms that bridge two wings — the hallways connecting different domains. E.g. what topics connect wing_code to wing_team?",
614
+ "input_schema": {
615
+ "type": "object",
616
+ "properties": {
617
+ "wing_a": {"type": "string", "description": "First wing (optional)"},
618
+ "wing_b": {"type": "string", "description": "Second wing (optional)"},
619
+ },
620
+ },
621
+ "handler": tool_find_tunnels,
622
+ },
623
+ "mempalace_graph_stats": {
624
+ "description": "Palace graph overview: total rooms, tunnel connections, edges between wings.",
625
+ "input_schema": {"type": "object", "properties": {}},
626
+ "handler": tool_graph_stats,
627
+ },
628
+ "mempalace_search": {
629
+ "description": "Semantic search. Returns verbatim drawer content with similarity scores. Each hit includes wing, room, source_file, symbol_name, symbol_type, language, and similarity.",
630
+ "input_schema": {
631
+ "type": "object",
632
+ "properties": {
633
+ "query": {"type": "string", "description": "What to search for"},
634
+ "limit": {"type": "integer", "description": "Max results (default 5)"},
635
+ "wing": {"type": "string", "description": "Filter by wing (optional)"},
636
+ "room": {"type": "string", "description": "Filter by room (optional)"},
637
+ },
638
+ "required": ["query"],
639
+ },
640
+ "handler": tool_search,
641
+ },
642
+ "mempalace_code_search": {
643
+ "description": (
644
+ "Code-optimized search. Returns symbol name, type, language, and file path per hit. "
645
+ "Use this instead of mempalace_search when looking for code symbols, functions, or files."
646
+ ),
647
+ "input_schema": {
648
+ "type": "object",
649
+ "properties": {
650
+ "query": {"type": "string", "description": "What to search for"},
651
+ "language": {
652
+ "type": "string",
653
+ "description": "Filter by language (e.g. python, go, typescript, rust, sql, html, css)",
654
+ },
655
+ "symbol_name": {
656
+ "type": "string",
657
+ "description": "Filter by symbol name — case-insensitive substring match",
658
+ },
659
+ "symbol_type": {
660
+ "type": "string",
661
+ "description": "Filter by symbol type (function, class, method, struct, interface)",
662
+ },
663
+ "file_glob": {
664
+ "type": "string",
665
+ "description": "Filter by file path glob (e.g. */mempalace/*.py)",
666
+ },
667
+ "wing": {"type": "string", "description": "Filter by wing (optional)"},
668
+ "n_results": {
669
+ "type": "integer",
670
+ "description": "Max results to return, 1–50 (default 10)",
671
+ },
672
+ },
673
+ "required": ["query"],
674
+ },
675
+ "handler": tool_code_search,
676
+ },
677
+ "mempalace_check_duplicate": {
678
+ "description": "Check if content already exists in the palace before filing",
679
+ "input_schema": {
680
+ "type": "object",
681
+ "properties": {
682
+ "content": {"type": "string", "description": "Content to check"},
683
+ "threshold": {
684
+ "type": "number",
685
+ "description": "Similarity threshold 0-1 (default 0.9)",
686
+ },
687
+ },
688
+ "required": ["content"],
689
+ },
690
+ "handler": tool_check_duplicate,
691
+ },
692
+ "mempalace_add_drawer": {
693
+ "description": "File verbatim content into the palace. Checks for duplicates first.",
694
+ "input_schema": {
695
+ "type": "object",
696
+ "properties": {
697
+ "wing": {"type": "string", "description": "Wing (project name)"},
698
+ "room": {
699
+ "type": "string",
700
+ "description": "Room (aspect: backend, decisions, meetings...)",
701
+ },
702
+ "content": {
703
+ "type": "string",
704
+ "description": "Verbatim content to store — exact words, never summarized",
705
+ },
706
+ "source_file": {"type": "string", "description": "Where this came from (optional)"},
707
+ "added_by": {"type": "string", "description": "Who is filing this (default: mcp)"},
708
+ },
709
+ "required": ["wing", "room", "content"],
710
+ },
711
+ "handler": tool_add_drawer,
712
+ },
713
+ "mempalace_delete_drawer": {
714
+ "description": "Delete a drawer by ID. Irreversible.",
715
+ "input_schema": {
716
+ "type": "object",
717
+ "properties": {
718
+ "drawer_id": {"type": "string", "description": "ID of the drawer to delete"},
719
+ },
720
+ "required": ["drawer_id"],
721
+ },
722
+ "handler": tool_delete_drawer,
723
+ },
724
+ "mempalace_delete_wing": {
725
+ "description": "Delete ALL drawers in a wing. IRREVERSIBLE — use before re-mining a project to clear stale data.",
726
+ "input_schema": {
727
+ "type": "object",
728
+ "properties": {
729
+ "wing": {
730
+ "type": "string",
731
+ "description": "Wing name whose drawers should be deleted",
732
+ },
733
+ },
734
+ "required": ["wing"],
735
+ },
736
+ "handler": tool_delete_wing,
737
+ },
738
+ "mempalace_diary_write": {
739
+ "description": "Write a session diary entry for an agent. Record observations, thoughts, what you worked on, what matters. Each agent has their own diary wing with full history.",
740
+ "input_schema": {
741
+ "type": "object",
742
+ "properties": {
743
+ "agent_name": {
744
+ "type": "string",
745
+ "description": "Your name — each agent gets their own diary wing",
746
+ },
747
+ "entry": {
748
+ "type": "string",
749
+ "description": "Your diary entry — plain text",
750
+ },
751
+ "topic": {
752
+ "type": "string",
753
+ "description": "Topic tag (optional, default: general)",
754
+ },
755
+ },
756
+ "required": ["agent_name", "entry"],
757
+ },
758
+ "handler": tool_diary_write,
759
+ },
760
+ "mempalace_diary_read": {
761
+ "description": "Read your recent diary entries. See what past versions of yourself recorded — your journal across sessions.",
762
+ "input_schema": {
763
+ "type": "object",
764
+ "properties": {
765
+ "agent_name": {
766
+ "type": "string",
767
+ "description": "Your name — each agent gets their own diary wing",
768
+ },
769
+ "last_n": {
770
+ "type": "integer",
771
+ "description": "Number of recent entries to read (default: 10)",
772
+ },
773
+ },
774
+ "required": ["agent_name"],
775
+ },
776
+ "handler": tool_diary_read,
777
+ },
778
+ }
779
+
780
+
781
+ def handle_request(request):
782
+ method = request.get("method", "")
783
+ params = request.get("params", {})
784
+ req_id = request.get("id")
785
+
786
+ if method == "initialize":
787
+ return {
788
+ "jsonrpc": "2.0",
789
+ "id": req_id,
790
+ "result": {
791
+ "protocolVersion": "2024-11-05",
792
+ "capabilities": {"tools": {}},
793
+ "serverInfo": {"name": "mempalace", "version": __version__},
794
+ },
795
+ }
796
+ elif method == "notifications/initialized":
797
+ return None
798
+ elif method == "tools/list":
799
+ return {
800
+ "jsonrpc": "2.0",
801
+ "id": req_id,
802
+ "result": {
803
+ "tools": [
804
+ {"name": n, "description": t["description"], "inputSchema": t["input_schema"]}
805
+ for n, t in TOOLS.items()
806
+ ]
807
+ },
808
+ }
809
+ elif method == "tools/call":
810
+ tool_name = params.get("name")
811
+ tool_args = params.get("arguments", {})
812
+ if tool_name not in TOOLS:
813
+ return {
814
+ "jsonrpc": "2.0",
815
+ "id": req_id,
816
+ "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"},
817
+ }
818
+ # Coerce argument types based on input_schema.
819
+ # MCP JSON transport may deliver integers as floats or strings;
820
+ # ChromaDB and Python slicing require native int.
821
+ schema_props = TOOLS[tool_name]["input_schema"].get("properties", {})
822
+ for key, value in list(tool_args.items()):
823
+ prop_schema = schema_props.get(key, {})
824
+ declared_type = prop_schema.get("type")
825
+ if declared_type == "integer" and not isinstance(value, int):
826
+ tool_args[key] = int(value)
827
+ elif declared_type == "number" and not isinstance(value, (int, float)):
828
+ tool_args[key] = float(value)
829
+ try:
830
+ result = TOOLS[tool_name]["handler"](**tool_args)
831
+ return {
832
+ "jsonrpc": "2.0",
833
+ "id": req_id,
834
+ "result": {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]},
835
+ }
836
+ except Exception:
837
+ logger.exception(f"Tool error in {tool_name}")
838
+ return {
839
+ "jsonrpc": "2.0",
840
+ "id": req_id,
841
+ "error": {"code": -32000, "message": "Internal tool error"},
842
+ }
843
+
844
+ return {
845
+ "jsonrpc": "2.0",
846
+ "id": req_id,
847
+ "error": {"code": -32601, "message": f"Unknown method: {method}"},
848
+ }
849
+
850
+
851
+ def main():
852
+ logger.info("MemPalace MCP Server starting...")
853
+ while True:
854
+ try:
855
+ line = sys.stdin.readline()
856
+ if not line:
857
+ break
858
+ line = line.strip()
859
+ if not line:
860
+ continue
861
+ request = json.loads(line)
862
+ response = handle_request(request)
863
+ if response is not None:
864
+ sys.stdout.write(json.dumps(response) + "\n")
865
+ sys.stdout.flush()
866
+ except KeyboardInterrupt:
867
+ break
868
+ except Exception as e:
869
+ logger.error(f"Server error: {e}")
870
+
871
+
872
+ if __name__ == "__main__":
873
+ main()