mem-brain-mcp 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,939 @@
1
+ """MCP Server for Mem-Brain API using FastMCP."""
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, Union
5
+ import httpx
6
+ from fastmcp import FastMCP
7
+ from fastmcp.server.context import request_ctx
8
+ from fastmcp.exceptions import ToolError
9
+ from fastmcp.prompts.prompt import PromptMessage, TextContent
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse
12
+
13
+ from mem_brain_mcp.client import APIClient
14
+ from mem_brain_mcp.config import settings
15
+
16
+ # The comprehensive agent instructions (embedded for MCP distribution)
17
+ AGENT_INSTRUCTIONS = """You are an intelligent assistant with a persistent, evolving memory graph.
18
+
19
+ ## 🎯 CORE DIRECTIVE
20
+ **Synthesize**, don't just retrieve. Connect user's request to their past preferences, habits, and constraints.
21
+
22
+ ## πŸ” MEMORY WORKFLOW
23
+
24
+ **1. SEARCH FIRST & SMART** β€” Before answering personal questions, call `search_memories`.
25
+ - **Formulate specific, natural language queries**, NOT simple keywords.
26
+ - ❌ `query="maga"` (Weak)
27
+ - βœ… `query="Who is Maga and what is his relationship to me?"` (Strong - matches both memory content & link descriptions)
28
+ - Check `related_memories` field β€” these are auto-expanded graph neighbors.
29
+ - Synthesize: If "coffee" result links to "acid reflux", suggest cold brew.
30
+
31
+ **2. PATTERN RECOGNITION** β€” Don't just echo memories back.
32
+ - ❌ "I see a memory that says you like navy"
33
+ - βœ… "This matches the navy aesthetic you've been leaning into"
34
+
35
+ **3. PASSIVE STORAGE** β€” When user reveals preferences, store the **FACT** (not conversation).
36
+ - User: "I think I wanna try that sushi spot" β†’ Store: "User interested in new sushi restaurant"
37
+
38
+ **4. KEEP IT CURRENT** β€” If user contradicts a past memory, use `update_memory`.
39
+
40
+ ---
41
+
42
+ ## πŸ› οΈ TOOLS
43
+
44
+ ### Core Operations
45
+
46
+ | Tool | When to Use |
47
+ |------|-------------|
48
+ | `search_memories(query, k=5)` | Before answering ANY personal question |
49
+ | `get_memories(memory_ids)` | Need full details for specific IDs |
50
+ | `add_memory(content, tags=[], category="")` | User reveals preference/goal/fact |
51
+ | `update_memory(memory_id, content=..., tags=...)` | Information evolves or changes |
52
+ | `delete_memories(memory_id)` | Memory is wrong or user requests deletion |
53
+ | `unlink_memories(id1, id2)` | Connection no longer relevant |
54
+ | `get_stats()` | User asks "how much do you remember?" |
55
+
56
+ ### Graph Intelligence (Advanced)
57
+
58
+ | Tool | Purpose | Example |
59
+ |------|---------|---------|
60
+ | `find_path(from_id, to_id)` | Explain connections | "How is coffee related to health?" → Shows: Coffee→Caffeine→Health |
61
+ | `get_neighborhood(memory_id, hops=2)` | Deep context | Get 2-hop radius around a memory |
62
+
63
+ ---
64
+
65
+ ## πŸ“ STORAGE GUIDELINES
66
+
67
+ **Write FACTS, not conversation:**
68
+ - βœ… "User prefers dark mode interfaces"
69
+ - ❌ "You said you like dark mode"
70
+
71
+ **Tagging patterns:**
72
+ - Domain: `health`, `work`, `finance`, `tech`, `food`, `travel`
73
+ - Type: `preference`, `constraint`, `goal`, `fact`, `event`
74
+ - Priority: `important`, `routine`, `temporary`
75
+
76
+ **Avoiding duplicates:**
77
+ 1. If you already searched β†’ check if memory exists before adding
78
+ 2. If similar memory exists β†’ `update_memory` instead
79
+ 3. If you haven't searched β†’ just add it, evolution handles linking
80
+
81
+ ---
82
+
83
+ ## πŸ”„ CHANGING PREFERENCES
84
+
85
+ | Signal | Action |
86
+ |--------|--------|
87
+ | "I'm trying X", "exploring Y" | ADD new memory (temporary exploration) |
88
+ | "I no longer like X", "I switched to Y" | UPDATE existing memory (permanent change) |
89
+ | Contradictory with equal weight | ADD with temporal context ("as of 2025") |
90
+
91
+ ---
92
+
93
+ ## ⚑ ARCHITECTURE (Brief)
94
+
95
+ - **Graph Structure**: Memories = nodes, links = edges
96
+ - **Search**: Semantic similarity (70%) + importance/connections (30%)
97
+ - **Auto-linking**: System creates links for narrative/causal connections
98
+ - **User isolation**: Separate database per user
99
+
100
+ ---
101
+
102
+ ## βœ… BEST PRACTICES
103
+
104
+ | DO | DON'T |
105
+ |----|-------|
106
+ | Search before answering personal Q's | Guess without searching |
107
+ | Check `related_memories` field | Ignore graph connections |
108
+ | Store explicit facts | Store vague conversation |
109
+ | Update when info changes | Create duplicates |
110
+ | Synthesize across memories | Just list facts |
111
+
112
+ **Remember:** You're not a database. Connect the dots to provide thoughtful, personalized responses."""
113
+
114
+
115
+ def _get_request_token() -> Optional[str]:
116
+ """Extract JWT token from request headers.
117
+
118
+ Returns:
119
+ JWT token string if found, None otherwise
120
+ """
121
+ try:
122
+ ctx = request_ctx.get()
123
+ if hasattr(ctx, 'request') and hasattr(ctx.request, 'headers'):
124
+ headers = ctx.request.headers
125
+ # Try Authorization Bearer token (primary method)
126
+ auth_header = headers.get('authorization', '') or headers.get('Authorization', '')
127
+ if auth_header.startswith('Bearer '):
128
+ return auth_header[7:]
129
+ # Fallback to X-API-Key header (for backward compatibility)
130
+ api_key = headers.get('x-api-key') or headers.get('X-API-Key')
131
+ if api_key:
132
+ return api_key
133
+ except Exception as e:
134
+ logger.debug(f"Could not extract token from request: {e}")
135
+ return None
136
+
137
+
138
+ logger = logging.getLogger(__name__)
139
+
140
+
141
+ async def _get_api_client() -> APIClient:
142
+ """Get API client with per-request JWT token."""
143
+ token = _get_request_token()
144
+ if token:
145
+ return APIClient(api_key=token) # api_key parameter now holds JWT token
146
+ # Fallback to config API key (for single-user scenarios)
147
+ if settings.api_key:
148
+ logger.debug("Using config API key as fallback")
149
+ return api_client # Global instance
150
+ # No token available
151
+ raise ToolError("No authentication token provided. Please login using the login tool or configure your JWT token in your MCP client headers.")
152
+
153
+
154
+ # Initialize FastMCP server
155
+ mcp = FastMCP("Mem-Brain MCP")
156
+
157
+ # Initialize API client
158
+ api_client = APIClient()
159
+
160
+
161
+ async def _get_dynamic_context() -> str:
162
+ """Fetch dynamic context (core identity + recent memories) from API."""
163
+ try:
164
+ # Get core identity
165
+ client = await _get_api_client()
166
+ identity_response = await client._request("POST", "/memories/search", json={
167
+ "query": "user name location job identity", "k": 10
168
+ })
169
+
170
+ identity_memories = []
171
+ for mem in identity_response.get("results", []):
172
+ tags = mem.get('tags', [])
173
+ if any(tag in tags for tag in {'user_info', 'name', 'location', 'job', 'core_identity', 'identity', 'personal'}):
174
+ identity_memories.append(mem)
175
+
176
+ identity_section = ""
177
+ if identity_memories:
178
+ identity_section = "## 🧬 Core Identity\n"
179
+ for memory in identity_memories[:3]:
180
+ identity_section += f"- {memory['content']}\n"
181
+
182
+ # Get recent context
183
+ recent_response = await client._request("POST", "/memories/search", json={
184
+ "query": "recent context", "k": 3
185
+ })
186
+
187
+ recent_section = ""
188
+ if recent_response.get("results"):
189
+ recent_section = "## πŸ• Recent Context\n"
190
+ for memory in recent_response.get("results", [])[:3]:
191
+ content = memory['content']
192
+ truncated = content[:100] + '...' if len(content) > 100 else content
193
+ recent_section += f"- {truncated}\n"
194
+
195
+ return f"""### 🧠 YOUR BRAIN (Current Working Context)
196
+ {identity_section if identity_section else "*No core identity established yet*"}
197
+ {recent_section if recent_section else "*No recent context*"}
198
+
199
+ ---
200
+
201
+ """
202
+ except Exception as e:
203
+ logger.warning(f"Could not fetch dynamic context: {e}")
204
+ return """### 🧠 YOUR BRAIN (Current Working Context)
205
+ *Context loading failed - API may be unavailable*
206
+
207
+ ---
208
+
209
+ """
210
+
211
+
212
+ # ============================================================================
213
+ # RESOURCES (Documentation that LLMs can read)
214
+ # ============================================================================
215
+
216
+ @mcp.resource("mem-brain://docs/workflow-guide")
217
+ def workflow_guide() -> str:
218
+ """Complete guide to the memory workflow: search strategies, pattern recognition, storage guidelines, and best practices."""
219
+ return """# A-Mem Workflow Guide
220
+
221
+ ## 🎯 CORE DIRECTIVE
222
+ **Synthesize**, don't just retrieve. Connect user's request to their past preferences, habits, and constraints.
223
+
224
+ ## πŸ” MEMORY WORKFLOW
225
+
226
+ **1. SEARCH FIRST & SMART** β€” Before answering personal questions, call `search_memories`.
227
+ - **Formulate specific, natural language queries**, NOT simple keywords.
228
+ - ❌ `query="maga"` (Weak)
229
+ - βœ… `query="Who is Maga and what is his relationship to me?"` (Strong - matches both memory content & link descriptions)
230
+ - Check `related_memories` field β€” these are auto-expanded graph neighbors.
231
+ - Synthesize: If "coffee" result links to "acid reflux", suggest cold brew.
232
+
233
+ **2. PATTERN RECOGNITION** β€” Don't just echo memories back.
234
+ - ❌ "I see a memory that says you like navy"
235
+ - βœ… "This matches the navy aesthetic you've been leaning into"
236
+
237
+ **3. PASSIVE STORAGE** β€” When user reveals preferences, store the **FACT** (not conversation).
238
+ - User: "I think I wanna try that sushi spot" β†’ Store: "User interested in new sushi restaurant"
239
+
240
+ **4. KEEP IT CURRENT** β€” If user contradicts a past memory, use `update_memory`.
241
+
242
+ ## βœ… BEST PRACTICES
243
+
244
+ | DO | DON'T |
245
+ |----|-------|
246
+ | Search before answering personal Q's | Guess without searching |
247
+ | Check `related_memories` field | Ignore graph connections |
248
+ | Store explicit facts | Store vague conversation |
249
+ | Update when info changes | Create duplicates |
250
+ | Synthesize across memories | Just list facts |
251
+
252
+ **Remember:** You're not a database. Connect the dots to provide thoughtful, personalized responses.
253
+ """
254
+
255
+
256
+ @mcp.resource("mem-brain://docs/tool-reference")
257
+ def tool_reference() -> str:
258
+ """Detailed reference for when and how to use each memory tool effectively."""
259
+ return """# Tool Usage Reference
260
+
261
+ ## Core Operations
262
+
263
+ ### `search_memories(query, k=5)`
264
+ **When to Use**: Before answering ANY personal question
265
+ **Critical**: Formulate specific, natural language queries, NOT simple keywords
266
+ - βœ… Good: "Who is Maga and what is their relationship to me?"
267
+ - ❌ Bad: "maga"
268
+
269
+ ### `get_memories(memory_ids)`
270
+ **When to Use**: Need full details for specific IDs identified from search results
271
+
272
+ ### `add_memory(content, tags=[], category="")`
273
+ **When to Use**: User reveals preference/goal/fact
274
+ **Storage Rule**: Store FACTS, not conversation
275
+ - βœ… "User prefers dark mode interfaces"
276
+ - ❌ "You said you like dark mode"
277
+
278
+ ### `update_memory(memory_id, content=..., tags=...)`
279
+ **When to Use**: Information evolves or changes, user contradicts past memory
280
+
281
+ ### `delete_memories(memory_id)`
282
+ **When to Use**: Memory is wrong or user explicitly requests deletion
283
+
284
+ ### `unlink_memories(id1, id2)`
285
+ **When to Use**: Connection no longer relevant or accurate
286
+
287
+ ### `get_stats()`
288
+ **When to Use**: User asks "how much do you remember?" or wants overview
289
+
290
+ ## Graph Intelligence
291
+
292
+ ### `find_path(from_id, to_id)`
293
+ **Purpose**: Explain connections between memories
294
+ **Example**: "How is coffee related to health?" → Shows path: Coffee→Caffeine→Health
295
+
296
+ ### `get_neighborhood(memory_id, hops=2)`
297
+ **Purpose**: Get deep context around a memory
298
+ **Use Case**: Understanding relationships around important memories
299
+ """
300
+
301
+
302
+ @mcp.resource("mem-brain://docs/storage-guidelines")
303
+ def storage_guidelines() -> str:
304
+ """Best practices for storing facts, tagging patterns, and avoiding duplicates."""
305
+ return """# Storage Guidelines
306
+
307
+ ## Write FACTS, not conversation
308
+
309
+ - βœ… "User prefers dark mode interfaces"
310
+ - ❌ "You said you like dark mode"
311
+
312
+ ## Tagging Patterns
313
+
314
+ **Domains**: `health`, `work`, `finance`, `tech`, `food`, `travel`
315
+ **Types**: `preference`, `constraint`, `goal`, `fact`, `event`
316
+ **Priority**: `important`, `routine`, `temporary`
317
+
318
+ ## Avoiding Duplicates
319
+
320
+ 1. If you already searched β†’ check if memory exists before adding
321
+ 2. If similar memory exists β†’ `update_memory` instead
322
+ 3. If you haven't searched β†’ just add it, evolution handles linking
323
+
324
+ ## Changing Preferences
325
+
326
+ | Signal | Action |
327
+ |--------|--------|
328
+ | "I'm trying X", "exploring Y" | ADD new memory (temporary exploration) |
329
+ | "I no longer like X", "I switched to Y" | UPDATE existing memory (permanent change) |
330
+ | Contradictory with equal weight | ADD with temporal context ("as of 2025") |
331
+
332
+ ## Architecture
333
+
334
+ - **Graph Structure**: Memories = nodes, links = edges
335
+ - **Search**: Semantic similarity (70%) + importance/connections (30%)
336
+ - **Auto-linking**: System creates links for narrative/causal connections
337
+ - **User isolation**: Separate database per user
338
+ """
339
+
340
+
341
+ # ============================================================================
342
+ # PROMPTS (Bootstrap Intelligence)
343
+ # ============================================================================
344
+
345
+ @mcp.prompt
346
+ async def setup_personal_memory() -> PromptMessage:
347
+ """Initializes the assistant with the user's identity, recent context, and memory management rules. Run this once at the start of a session."""
348
+ context_section = await _get_dynamic_context()
349
+
350
+ full_instructions = f"""{context_section}{AGENT_INSTRUCTIONS}
351
+
352
+ **Note**: For detailed tool usage, see resource: `mem-brain://docs/tool-reference`
353
+ For storage guidelines, see resource: `mem-brain://docs/storage-guidelines`
354
+ """
355
+
356
+ return PromptMessage(
357
+ role="system",
358
+ content=TextContent(type="text", text=full_instructions)
359
+ )
360
+
361
+
362
+ @mcp.prompt
363
+ async def refresh_context() -> PromptMessage:
364
+ """Refreshes the assistant's context with updated core identity and recent memories. Use when context feels stale."""
365
+ context_section = await _get_dynamic_context()
366
+
367
+ return PromptMessage(
368
+ role="system",
369
+ content=TextContent(
370
+ type="text",
371
+ text=f"""{context_section}
372
+
373
+ **Context refreshed.** Continue using memory tools as before.
374
+ """
375
+ )
376
+ )
377
+
378
+
379
+ # ============================================================================
380
+ # TOOLS (Operations)
381
+ # ============================================================================
382
+
383
+ @mcp.tool()
384
+ async def login(email: str, password: str) -> str:
385
+ """Login to mem-brain API and get JWT token. Store the token for subsequent requests.
386
+
387
+ Args:
388
+ email: User email address
389
+ password: User password
390
+
391
+ Returns:
392
+ Success message with instructions for using the token
393
+ """
394
+ try:
395
+ import httpx
396
+ async with httpx.AsyncClient(timeout=30.0) as client:
397
+ response = await client.post(
398
+ f"{settings.api_base_url}/api/v1/auth/login",
399
+ json={"email": email, "password": password},
400
+ headers={"Content-Type": "application/json"}
401
+ )
402
+ response.raise_for_status()
403
+ data = response.json()
404
+
405
+ access_token = data.get("access_token")
406
+ refresh_token = data.get("refresh_token")
407
+
408
+ if access_token:
409
+ # Store tokens in context (in a real implementation, you'd use FastMCP context)
410
+ logger.info(f"Login successful for {email}")
411
+ return f"""Login successful!
412
+
413
+ Access Token: {access_token[:50]}...
414
+
415
+ Use this token in the Authorization header for subsequent requests:
416
+ Authorization: Bearer {access_token}
417
+
418
+ The token will be automatically used for all memory operations.
419
+ Refresh token saved for automatic token renewal."""
420
+ else:
421
+ raise ToolError("Login failed: No token received")
422
+ except httpx.HTTPStatusError as e:
423
+ if e.response.status_code == 401:
424
+ raise ToolError("Login failed: Invalid email or password")
425
+ logger.error(f"Login error: {e.response.status_code} - {e.response.text}")
426
+ raise ToolError(f"Login failed: {e.response.status_code}")
427
+ except Exception as e:
428
+ logger.error(f"Unexpected login error: {e}", exc_info=True)
429
+ raise ToolError(f"Login failed: {str(e)}")
430
+
431
+
432
+ @mcp.tool()
433
+ async def signup(email: str, password: str, full_name: str, organization_name: str) -> str:
434
+ """Sign up for a new mem-brain account and organization.
435
+
436
+ Args:
437
+ email: User email address
438
+ password: User password (min 8 chars, must contain letters and numbers)
439
+ full_name: User's full name
440
+ organization_name: Name for your organization
441
+
442
+ Returns:
443
+ Success message with access token
444
+ """
445
+ try:
446
+ import httpx
447
+ async with httpx.AsyncClient(timeout=30.0) as client:
448
+ response = await client.post(
449
+ f"{settings.api_base_url}/api/v1/auth/signup",
450
+ json={
451
+ "email": email,
452
+ "password": password,
453
+ "full_name": full_name,
454
+ "organization_name": organization_name
455
+ },
456
+ headers={"Content-Type": "application/json"}
457
+ )
458
+ response.raise_for_status()
459
+ data = response.json()
460
+
461
+ access_token = data.get("access_token")
462
+
463
+ if access_token:
464
+ logger.info(f"Signup successful for {email}")
465
+ return f"""Account created successfully!
466
+
467
+ Organization: {organization_name}
468
+ Email: {email}
469
+
470
+ Access Token: {access_token[:50]}...
471
+
472
+ Use this token in the Authorization header for subsequent requests:
473
+ Authorization: Bearer {access_token}
474
+
475
+ The token will be automatically used for all memory operations."""
476
+ else:
477
+ raise ToolError("Signup failed: No token received")
478
+ except httpx.HTTPStatusError as e:
479
+ error_detail = e.response.text if e.response else "Unknown error"
480
+ logger.error(f"Signup error: {e.response.status_code} - {error_detail}")
481
+ if e.response.status_code == 400:
482
+ raise ToolError(f"Signup failed: {error_detail}")
483
+ raise ToolError(f"Signup failed: {e.response.status_code}")
484
+ except Exception as e:
485
+ logger.error(f"Unexpected signup error: {e}", exc_info=True)
486
+ raise ToolError(f"Signup failed: {str(e)}")
487
+
488
+
489
+ @mcp.tool()
490
+ async def get_agent_instructions(include_dynamic_context: bool = True) -> str:
491
+ """Get comprehensive system prompt and best practices for using the memory system effectively. This contains the intelligence for smart memory management, search strategies, and agent workflows."""
492
+ if include_dynamic_context:
493
+ context_section = await _get_dynamic_context()
494
+ else:
495
+ context_section = ""
496
+
497
+ return context_section + AGENT_INSTRUCTIONS
498
+
499
+
500
+ @mcp.tool()
501
+ async def add_memory(
502
+ content: str,
503
+ tags: Optional[List[str]] = None,
504
+ category: Optional[str] = None
505
+ ) -> str:
506
+ """Create a new memory with content, optional tags, and category. Use this when user reveals preferences, goals, or facts. Store FACTS, not conversation. Examples: 'User prefers dark mode' vs 'You said you like dark mode'. See mem-brain://docs/storage-guidelines for tagging patterns. DO NOT store conversation snippets - only store factual information.
507
+
508
+ IMPORTANT: Before creating a new memory, you MUST first search existing memories using search_memories() to check if a similar memory already exists. This prevents duplicates and helps maintain memory quality. Only create a new memory if no similar memory is found.
509
+
510
+ Parameters:
511
+ content (str, required): The memory content to store. Must be a non-empty string. Example: "User prefers Python over JavaScript"
512
+ tags (list[str], optional): List of tags to categorize the memory. Example: ["coding", "preferences"] or None
513
+ category (str, optional): Category name for the memory. Example: "interests" or None
514
+
515
+ Returns:
516
+ str: A formatted string with the memory ID and details of the created memory.
517
+
518
+ Example workflow:
519
+ 1. search_memories(query="User prefers Python") # Check for existing memories
520
+ 2. If no similar memory found, then: add_memory(content="User prefers Python over JavaScript", tags=["coding", "preferences"])
521
+
522
+ Example:
523
+ add_memory(
524
+ content="User loves working with TypeScript",
525
+ tags=["coding", "typescript"],
526
+ category="interests"
527
+ )
528
+ """
529
+ # Validate parameters with detailed error messages
530
+ if content is None:
531
+ raise ToolError("The 'content' parameter is required but was not provided. Please provide the memory content as a string.")
532
+
533
+ if not isinstance(content, str):
534
+ raise ToolError(f"The 'content' parameter must be a string, but got {type(content).__name__}. Please provide the memory content as a string.")
535
+
536
+ content_str = str(content).strip()
537
+ if not content_str:
538
+ raise ToolError("The 'content' parameter cannot be empty. Please provide the memory content as a non-empty string.")
539
+
540
+ try:
541
+ logger.info(f"add_memory called - content length: {len(content_str)}, tags: {tags}, category: {category}")
542
+ logger.debug(f"add_memory full content: {content_str[:100]}...")
543
+
544
+ # Normalize tags: convert empty list to None, ensure it's a list if provided
545
+ normalized_tags = None
546
+ if tags is not None:
547
+ if isinstance(tags, list):
548
+ normalized_tags = tags if tags else None # Empty list becomes None
549
+ elif isinstance(tags, str):
550
+ # Handle case where tags might be passed as a single string
551
+ normalized_tags = [tags]
552
+ else:
553
+ try:
554
+ normalized_tags = list(tags) if tags else None
555
+ except (TypeError, ValueError):
556
+ logger.warning(f"Could not convert tags to list: {tags}")
557
+ normalized_tags = None
558
+
559
+ # Normalize category: convert empty string to None
560
+ normalized_category = category.strip() if category and isinstance(category, str) and category.strip() else None
561
+
562
+ client = await _get_api_client()
563
+ logger.debug(f"Calling API client.add_memory with content='{content_str[:50]}...', tags={normalized_tags}, category={normalized_category}")
564
+
565
+ result = await client.add_memory(content_str, normalized_tags, normalized_category)
566
+
567
+ logger.info(f"Memory created successfully: {result.get('memory_id', 'unknown')}")
568
+ memory = result.get('memory')
569
+ if memory:
570
+ return f"Memory created: {result.get('memory_id', 'unknown')}\n{_format_memory(memory)}"
571
+ return f"Memory created: {result.get('memory_id', 'unknown')}"
572
+ except httpx.HTTPStatusError as e:
573
+ error_detail = e.response.text if e.response else "Unknown error"
574
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
575
+ if e.response.status_code == 401:
576
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
577
+ elif e.response.status_code == 400:
578
+ raise ToolError(f"Invalid request: {error_detail}")
579
+ raise ToolError(f"Failed to create memory: HTTP {e.response.status_code} - {error_detail}")
580
+ except ToolError:
581
+ raise
582
+ except Exception as e:
583
+ logger.error(f"Unexpected error in add_memory: {e}", exc_info=True)
584
+ raise ToolError(f"Error creating memory: {str(e)}")
585
+
586
+
587
+ @mcp.tool()
588
+ async def search_memories(query: str, k: int = 5) -> str:
589
+ """Search memories using semantic similarity. CRITICAL: Formulate specific, natural language queries, NOT simple keywords. Examples: βœ… 'Who is Maga and what is their relationship to me?' vs ❌ 'maga'. Check related_memories field for graph connections and synthesize across results. See mem-brain://docs/workflow-guide for search strategies. DO NOT use vague keywords - always use full questions."""
590
+ # Validate parameters
591
+ if not query or not query.strip():
592
+ raise ToolError("Query cannot be empty")
593
+ if not (1 <= k <= 100):
594
+ raise ToolError("k must be between 1 and 100")
595
+
596
+ try:
597
+ client = await _get_api_client()
598
+ result = await client.search_memories(query, k)
599
+ return f"Found {result.get('count', 0)} results:\n{_format_search_results(result.get('results', []))}"
600
+ except httpx.HTTPStatusError as e:
601
+ if e.response.status_code == 401:
602
+ raise ToolError("Authentication failed. Please check your API key.")
603
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
604
+ raise ToolError(f"Failed to search memories: {e.response.status_code}")
605
+ except Exception as e:
606
+ logger.error(f"Unexpected error: {e}", exc_info=True)
607
+ raise ToolError(f"Error searching memories: {str(e)}")
608
+
609
+
610
+ @mcp.tool()
611
+ async def get_memories(memory_ids: List[str]) -> str:
612
+ """Retrieve one or more memories by ID. Use this when you need full details for specific memories identified from search results."""
613
+ # Validate parameters
614
+ if not memory_ids:
615
+ raise ToolError("memory_ids cannot be empty")
616
+ for memory_id in memory_ids:
617
+ if not memory_id or not memory_id.strip():
618
+ raise ToolError("Memory IDs cannot be empty")
619
+
620
+ try:
621
+ client = await _get_api_client()
622
+ result = await client.get_memories(memory_ids)
623
+ memories = result.get("memories", [])
624
+ return f"Retrieved {len(memories)} memories:\n{_format_memories_list(memories)}"
625
+ except httpx.HTTPStatusError as e:
626
+ if e.response.status_code == 401:
627
+ raise ToolError("Authentication failed. Please check your API key.")
628
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
629
+ raise ToolError(f"Failed to get memories: {e.response.status_code}")
630
+ except Exception as e:
631
+ logger.error(f"Unexpected error: {e}", exc_info=True)
632
+ raise ToolError(f"Error getting memories: {str(e)}")
633
+
634
+
635
+ @mcp.tool()
636
+ async def update_memory(
637
+ memory_id: str,
638
+ content: Optional[str] = None,
639
+ tags: Optional[List[str]] = None
640
+ ) -> str:
641
+ """Update an existing memory when information evolves or changes. Use this when user contradicts a past memory ('I no longer like X') or when details need updating."""
642
+ # Validate parameters
643
+ if not memory_id or not memory_id.strip():
644
+ raise ToolError("memory_id cannot be empty")
645
+
646
+ try:
647
+ client = await _get_api_client()
648
+ result = await client.update_memory(memory_id, content, tags)
649
+ memory = result.get('memory')
650
+ if memory:
651
+ return f"Memory updated:\n{_format_memory(memory)}"
652
+ return f"Memory {memory_id} updated"
653
+ except httpx.HTTPStatusError as e:
654
+ if e.response.status_code == 401:
655
+ raise ToolError("Authentication failed. Please check your API key.")
656
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
657
+ raise ToolError(f"Failed to update memory: {e.response.status_code}")
658
+ except Exception as e:
659
+ logger.error(f"Unexpected error: {e}", exc_info=True)
660
+ raise ToolError(f"Error updating memory: {str(e)}")
661
+
662
+
663
+ @mcp.tool()
664
+ async def delete_memories(
665
+ memory_id: Optional[str] = None,
666
+ tags: Optional[str] = None,
667
+ category: Optional[str] = None
668
+ ) -> str:
669
+ """Delete memories by ID or by filter (tags/category). If memory_id is provided, it takes precedence over filters. Use for removing wrong memories or when user explicitly requests deletion."""
670
+ try:
671
+ client = await _get_api_client()
672
+ result = await client.delete_memories(memory_id, tags, category)
673
+ deleted_ids = result.get('memory_ids', [])[:10]
674
+ return f"Deleted {result.get('deleted_count', 0)} memories. IDs: {', '.join(deleted_ids)}"
675
+ except httpx.HTTPStatusError as e:
676
+ if e.response.status_code == 401:
677
+ raise ToolError("Authentication failed. Please check your API key.")
678
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
679
+ raise ToolError(f"Failed to delete memories: {e.response.status_code}")
680
+ except Exception as e:
681
+ logger.error(f"Unexpected error: {e}", exc_info=True)
682
+ raise ToolError(f"Error deleting memories: {str(e)}")
683
+
684
+
685
+ @mcp.tool()
686
+ async def unlink_memories(memory_id_1: str, memory_id_2: str) -> str:
687
+ """Remove link between two memories when the connection is no longer relevant or accurate."""
688
+ # Validate parameters
689
+ if not memory_id_1 or not memory_id_1.strip():
690
+ raise ToolError("memory_id_1 cannot be empty")
691
+ if not memory_id_2 or not memory_id_2.strip():
692
+ raise ToolError("memory_id_2 cannot be empty")
693
+
694
+ try:
695
+ client = await _get_api_client()
696
+ result = await client.unlink_memories(memory_id_1, memory_id_2)
697
+ return result.get("message", "Memories unlinked")
698
+ except httpx.HTTPStatusError as e:
699
+ if e.response.status_code == 401:
700
+ raise ToolError("Authentication failed. Please check your API key.")
701
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
702
+ raise ToolError(f"Failed to unlink memories: {e.response.status_code}")
703
+ except Exception as e:
704
+ logger.error(f"Unexpected error: {e}", exc_info=True)
705
+ raise ToolError(f"Error unlinking memories: {str(e)}")
706
+
707
+
708
+ @mcp.tool()
709
+ async def get_stats() -> str:
710
+ """Get memory system statistics including total memories, links, and top tags. Use this when user asks 'how much do you remember?' or wants an overview of their memory system."""
711
+ try:
712
+ client = await _get_api_client()
713
+ result = await client.get_stats()
714
+ top_tags = ', '.join([f"{tag}({count})" for tag, count in result.get('top_tags', [])[:10]])
715
+ return f"""Memory System Statistics:
716
+ Total Memories: {result.get('total_memories', 0)}
717
+ Total Links: {result.get('total_links', 0)}
718
+ Average Links per Memory: {result.get('avg_links_per_memory', 0):.2f}
719
+ Top Tags: {top_tags}"""
720
+ except httpx.HTTPStatusError as e:
721
+ if e.response.status_code == 401:
722
+ raise ToolError("Authentication failed. Please check your API key.")
723
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
724
+ raise ToolError(f"Failed to get stats: {e.response.status_code}")
725
+ except Exception as e:
726
+ logger.error(f"Unexpected error: {e}", exc_info=True)
727
+ raise ToolError(f"Error getting stats: {str(e)}")
728
+
729
+
730
+ @mcp.tool()
731
+ async def find_path(from_id: str, to_id: str) -> str:
732
+ """Find shortest path between two memories in the memory graph. Use this to explain connections between seemingly unrelated memories."""
733
+ # Validate parameters
734
+ if not from_id or not from_id.strip():
735
+ raise ToolError("from_id cannot be empty")
736
+ if not to_id or not to_id.strip():
737
+ raise ToolError("to_id cannot be empty")
738
+
739
+ try:
740
+ client = await _get_api_client()
741
+ result = await client.find_path(from_id, to_id)
742
+ if result.get("status") == "success":
743
+ path_text = f"Path found (length: {result.get('length', 0)}):\n"
744
+ for mem in result.get("memories", []):
745
+ path_text += f" - {mem.get('id', 'unknown')}: {mem.get('content', '')[:100]}\n"
746
+ return path_text
747
+ return result.get("message", "No path found")
748
+ except httpx.HTTPStatusError as e:
749
+ if e.response.status_code == 401:
750
+ raise ToolError("Authentication failed. Please check your API key.")
751
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
752
+ raise ToolError(f"Failed to find path: {e.response.status_code}")
753
+ except Exception as e:
754
+ logger.error(f"Unexpected error: {e}", exc_info=True)
755
+ raise ToolError(f"Error finding path: {str(e)}")
756
+
757
+
758
+ @mcp.tool()
759
+ async def get_neighborhood(memory_id: str, hops: int = 2) -> str:
760
+ """Get all memories within N hops of a given memory. Use this for deep context and understanding relationships around important memories."""
761
+ # Validate parameters
762
+ if not memory_id or not memory_id.strip():
763
+ raise ToolError("memory_id cannot be empty")
764
+ if not (1 <= hops <= 5):
765
+ raise ToolError("hops must be between 1 and 5")
766
+
767
+ try:
768
+ client = await _get_api_client()
769
+ result = await client.get_neighborhood(memory_id, hops)
770
+ neighborhood_text = f"Neighborhood (hops={result.get('hops', 2)}, total={result.get('total_in_neighborhood', 0)}):\n"
771
+ for mem in result.get("neighborhood", []):
772
+ hop_dist = mem.get("hop_distance", 0)
773
+ is_center = " (center)" if mem.get("is_center") else ""
774
+ neighborhood_text += f" [{hop_dist}]{is_center} {mem.get('id', 'unknown')}: {mem.get('content', '')[:100]}\n"
775
+ return neighborhood_text
776
+ except httpx.HTTPStatusError as e:
777
+ if e.response.status_code == 401:
778
+ raise ToolError("Authentication failed. Please check your API key.")
779
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
780
+ raise ToolError(f"Failed to get neighborhood: {e.response.status_code}")
781
+ except Exception as e:
782
+ logger.error(f"Unexpected error: {e}", exc_info=True)
783
+ raise ToolError(f"Error getting neighborhood: {str(e)}")
784
+
785
+
786
+ # ============================================================================
787
+ # HELPER FUNCTIONS
788
+ # ============================================================================
789
+
790
+ def _format_memory(memory: Optional[Dict[str, Any]]) -> str:
791
+ """Format a single memory for display."""
792
+ if not memory:
793
+ return "Memory data not available"
794
+
795
+ lines = [
796
+ f"ID: {memory.get('id', 'unknown')}",
797
+ f"Content: {memory.get('content', '')[:200]}",
798
+ f"Tags: {', '.join(memory.get('tags', []))}",
799
+ f"Context: {memory.get('context', 'N/A')}",
800
+ f"Links: {len(memory.get('links', []))} connections"
801
+ ]
802
+
803
+ # Add evolution history if available
804
+ evolution_history = memory.get('evolution_history', [])
805
+ if evolution_history:
806
+ lines.append(f"Evolution History: {len(evolution_history)} version(s)")
807
+ # Show current version first
808
+ current_content = memory.get('content', '')
809
+ lines.append(f" Current Version: {current_content}")
810
+ lines.append("")
811
+ # Show historical versions (oldest to newest)
812
+ for i, entry in enumerate(evolution_history, 1):
813
+ if entry.get('type') == 'content_update':
814
+ old_content = entry.get('old_content', '')
815
+ timestamp = entry.get('timestamp', 'unknown')
816
+ lines.append(f" Version {i} ({timestamp}): {old_content}")
817
+ elif entry.get('type') == 'evolution':
818
+ old_context = entry.get('old_context', 'N/A')
819
+ new_context = entry.get('new_context', 'N/A')
820
+ timestamp = entry.get('timestamp', 'unknown')
821
+ lines.append(f" Evolution {i} ({timestamp}): Context '{old_context}' β†’ '{new_context}'")
822
+
823
+ return "\n".join(lines)
824
+
825
+
826
+ def _format_memories_list(memories: List[Dict[str, Any]]) -> str:
827
+ """Format a list of memories for display."""
828
+ if not memories:
829
+ return "No memories found"
830
+
831
+ formatted = []
832
+ for i, mem in enumerate(memories, 1):
833
+ formatted.append(f"{i}. {_format_memory(mem)}")
834
+ return "\n\n".join(formatted)
835
+
836
+
837
+ def _format_search_results(results: List[Dict[str, Any]]) -> str:
838
+ """Format search results for display."""
839
+ if not results:
840
+ return "No results found"
841
+
842
+ formatted = []
843
+ for i, result in enumerate(results, 1):
844
+ if result.get("type") == "memory_node":
845
+ formatted.append(f"{i}. Memory Node (score: {result.get('semantic_score', 0):.3f})")
846
+ formatted.append(f" {_format_memory(result)}")
847
+ related = result.get("related_memories", [])
848
+ if related:
849
+ formatted.append(f" Related: {len(related)} memories")
850
+ elif result.get("type") == "relationship_edge":
851
+ formatted.append(f"{i}. Relationship Edge (score: {result.get('score', 0):.3f})")
852
+ source = result.get('source', {})
853
+ target = result.get('target', {})
854
+
855
+ # Show source node data
856
+ formatted.append(f" Source Node:")
857
+ formatted.append(f" ID: {source.get('id', 'unknown')}")
858
+ formatted.append(f" Content: {source.get('content', 'N/A')}")
859
+ if source.get('context') and source.get('context') != 'General':
860
+ formatted.append(f" Context: {source.get('context', 'N/A')}")
861
+ if source.get('tags'):
862
+ formatted.append(f" Tags: {', '.join(source.get('tags', []))}")
863
+ if source.get('keywords'):
864
+ formatted.append(f" Keywords: {', '.join(source.get('keywords', []))}")
865
+
866
+ # Show target node data
867
+ formatted.append(f" Target Node:")
868
+ formatted.append(f" ID: {target.get('id', 'unknown')}")
869
+ formatted.append(f" Content: {target.get('content', 'N/A')}")
870
+ if target.get('context') and target.get('context') != 'General':
871
+ formatted.append(f" Context: {target.get('context', 'N/A')}")
872
+ if target.get('tags'):
873
+ formatted.append(f" Tags: {', '.join(target.get('tags', []))}")
874
+ if target.get('keywords'):
875
+ formatted.append(f" Keywords: {', '.join(target.get('keywords', []))}")
876
+ formatted.append("")
877
+
878
+ return "\n".join(formatted)
879
+
880
+
881
+ # Add health check endpoint for load balancers
882
+ @mcp.custom_route("/health", methods=["GET"])
883
+ async def health_check(request: Request) -> JSONResponse:
884
+ """Health check endpoint for load balancers."""
885
+ return JSONResponse(
886
+ content={"status": "healthy", "service": "mem-brain-mcp"},
887
+ status_code=200
888
+ )
889
+
890
+
891
+ def run_server():
892
+ """Run the FastMCP server with HTTP transport."""
893
+ logger.info(f"Starting Mem-Brain MCP Server on {settings.mcp_server_host}:{settings.mcp_server_port}")
894
+ logger.info(f"API URL: {settings.api_url}")
895
+ logger.info(f"API Key: {'***' if settings.api_key else 'Not set'}")
896
+
897
+ # Configure CORS for browser-based and MCP clients
898
+ from starlette.middleware import Middleware
899
+ from starlette.middleware.cors import CORSMiddleware
900
+
901
+ middleware = [
902
+ Middleware(
903
+ CORSMiddleware,
904
+ allow_origins=["*"], # Allow all origins for MCP clients
905
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
906
+ allow_headers=[
907
+ "mcp-protocol-version",
908
+ "mcp-session-id",
909
+ "Authorization",
910
+ "Content-Type",
911
+ "Accept",
912
+ ],
913
+ expose_headers=["mcp-session-id"],
914
+ )
915
+ ]
916
+
917
+ # Use http_app with CORS middleware and run with uvicorn
918
+ # Note: http_app() handles the /mcp path automatically
919
+ app = mcp.http_app(middleware=middleware, path="/mcp")
920
+
921
+ # Import uvicorn (should be available via FastMCP dependencies)
922
+ try:
923
+ import uvicorn
924
+ except ImportError:
925
+ # Fallback: use mcp.run if uvicorn not available
926
+ logger.warning("uvicorn not available, using mcp.run() without CORS")
927
+ mcp.run(
928
+ transport="http",
929
+ host=settings.mcp_server_host,
930
+ port=settings.mcp_server_port,
931
+ path="/mcp"
932
+ )
933
+ return
934
+
935
+ uvicorn.run(
936
+ app,
937
+ host=settings.mcp_server_host,
938
+ port=settings.mcp_server_port,
939
+ )