mem-brain-mcp 1.0.6__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,1564 @@
1
+ """MCP Server for Mem-Brain API using FastMCP."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any, Dict, List, Optional, Union
6
+ import httpx
7
+ from fastmcp import FastMCP
8
+ from fastmcp.server.context import request_ctx
9
+ from fastmcp.exceptions import ToolError
10
+ from fastmcp.prompts.prompt import PromptMessage, TextContent
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse
13
+
14
+ from mem_brain_mcp.client import APIClient
15
+ from mem_brain_mcp.config import settings
16
+ from mem_brain_mcp import __version__
17
+
18
+ # The comprehensive agent instructions (embedded for MCP distribution)
19
+ AGENT_INSTRUCTIONS = """You are an intelligent assistant with a persistent, evolving memory graph.
20
+
21
+ ## 🎯 CORE DIRECTIVE
22
+ **Synthesize**, don't just retrieve. Connect user's request to their past preferences, habits, and constraints.
23
+
24
+ ## πŸ” MEMORY WORKFLOW
25
+
26
+ **1. SEARCH FIRST & SMART** β€” Before answering personal questions, call `search_memories`.
27
+ - **Formulate specific, natural language queries**, NOT simple keywords.
28
+ - ❌ `query="maga"` (Weak)
29
+ - βœ… `query="Who is Maga and what is his relationship to me?"` (Strong - matches both memory content & link descriptions)
30
+ - Check `related_memories` field β€” these are auto-expanded graph neighbors.
31
+ - Synthesize: If "coffee" result links to "acid reflux", suggest cold brew.
32
+
33
+ **2. PATTERN RECOGNITION** β€” Don't just echo memories back.
34
+ - ❌ "I see a memory that says you like navy"
35
+ - βœ… "This matches the navy aesthetic you've been leaning into"
36
+
37
+ **3. PASSIVE STORAGE** β€” When user reveals preferences, store the **FACT** (not conversation).
38
+ - User: "I think I wanna try that sushi spot" β†’ Store: "User interested in new sushi restaurant"
39
+
40
+ **4. KEEP IT CURRENT** β€” If user contradicts a past memory, use `update_memory`.
41
+
42
+ ---
43
+
44
+ ## πŸ› οΈ TOOLS
45
+
46
+ ### Core Operations
47
+
48
+ | Tool | When to Use |
49
+ |------|-------------|
50
+ | `search_memories(query, k=5)` | Before answering ANY personal question |
51
+ | `get_memories(memory_ids)` | Need full details for specific IDs |
52
+ | `add_memory(content, tags=[], category="")` | User reveals preference/goal/fact |
53
+ | `update_memory(memory_id, content=..., tags=...)` | Information evolves or changes |
54
+ | `delete_memories(memory_id)` | Memory is wrong or user requests deletion |
55
+ | `unlink_memories(id1, id2)` | Connection no longer relevant |
56
+ | `get_stats()` | User asks "how much do you remember?" |
57
+
58
+ ### Graph Intelligence (Advanced)
59
+
60
+ | Tool | Purpose | Example |
61
+ |------|---------|---------|
62
+ | `find_path(from_id, to_id)` | Explain connections | "How is coffee related to health?" → Shows: Coffee→Caffeine→Health |
63
+ | `get_neighborhood(memory_id, hops=2)` | Deep context | Get 2-hop radius around a memory |
64
+
65
+ ---
66
+
67
+ ## πŸ“ STORAGE GUIDELINES
68
+
69
+ **Write FACTS, not conversation:**
70
+ - βœ… "User prefers dark mode interfaces"
71
+ - ❌ "You said you like dark mode"
72
+
73
+ **Tagging patterns:**
74
+ - Domain: `health`, `work`, `finance`, `tech`, `food`, `travel`
75
+ - Type: `preference`, `constraint`, `goal`, `fact`, `event`
76
+ - Priority: `important`, `routine`, `temporary`
77
+
78
+ **Avoiding duplicates:**
79
+ 1. If you already searched β†’ check if memory exists before adding
80
+ 2. If similar memory exists β†’ `update_memory` instead
81
+ 3. If you haven't searched β†’ just add it, evolution handles linking
82
+
83
+ ---
84
+
85
+ ## πŸ”„ CHANGING PREFERENCES
86
+
87
+ | Signal | Action |
88
+ |--------|--------|
89
+ | "I'm trying X", "exploring Y" | ADD new memory (temporary exploration) |
90
+ | "I no longer like X", "I switched to Y" | UPDATE existing memory (permanent change) |
91
+ | Contradictory with equal weight | ADD with temporal context ("as of 2025") |
92
+
93
+ ---
94
+
95
+ ## ⚑ ARCHITECTURE (Brief)
96
+
97
+ - **Graph Structure**: Memories = nodes, links = edges
98
+ - **Search**: Semantic similarity (70%) + importance/connections (30%)
99
+ - **Auto-linking**: System creates links for narrative/causal connections
100
+ - **User isolation**: Separate database per user
101
+
102
+ ---
103
+
104
+ ## βœ… BEST PRACTICES
105
+
106
+ | DO | DON'T |
107
+ |----|-------|
108
+ | Search before answering personal Q's | Guess without searching |
109
+ | Check `related_memories` field | Ignore graph connections |
110
+ | Store explicit facts | Store vague conversation |
111
+ | Update when info changes | Create duplicates |
112
+ | Synthesize across memories | Just list facts |
113
+
114
+ **Remember:** You're not a database. Connect the dots to provide thoughtful, personalized responses."""
115
+
116
+
117
+ def _get_request_token() -> Optional[str]:
118
+ """Extract JWT token from request headers.
119
+
120
+ Returns:
121
+ JWT token string if found, None otherwise
122
+ """
123
+ try:
124
+ ctx = request_ctx.get()
125
+ if hasattr(ctx, 'request') and hasattr(ctx.request, 'headers'):
126
+ headers = ctx.request.headers
127
+ # Try Authorization Bearer token (primary method)
128
+ auth_header = headers.get('authorization', '') or headers.get('Authorization', '')
129
+ if auth_header.startswith('Bearer '):
130
+ return auth_header[7:]
131
+ # Fallback to X-API-Key header (for backward compatibility)
132
+ api_key = headers.get('x-api-key') or headers.get('X-API-Key')
133
+ if api_key:
134
+ return api_key
135
+ except Exception as e:
136
+ logger.debug(f"Could not extract token from request: {e}")
137
+ return None
138
+
139
+
140
+ logger = logging.getLogger(__name__)
141
+
142
+
143
+ async def _get_api_client() -> APIClient:
144
+ """Get API client with per-request JWT token."""
145
+ token = _get_request_token()
146
+ if token:
147
+ logger.debug(f"Using JWT token from request headers: {token[:20]}...")
148
+ client = APIClient(api_key=token) # api_key parameter now holds JWT token
149
+ logger.debug(f"API client created with base_url: {client.base_url}")
150
+ return client
151
+ # Fallback to config API key (for single-user scenarios)
152
+ if settings.api_key:
153
+ logger.debug("Using config API key as fallback")
154
+ logger.debug(f"API client (fallback) base_url: {api_client.base_url}")
155
+ return api_client # Global instance
156
+ # No token available
157
+ logger.error("No authentication token available - neither from headers nor config")
158
+ raise ToolError("No authentication token provided. Please login using the login tool or configure your JWT token in your MCP client headers.")
159
+
160
+
161
+ # Initialize FastMCP server
162
+ mcp = FastMCP("Mem-Brain MCP")
163
+
164
+ # Initialize API client
165
+ api_client = APIClient()
166
+
167
+
168
+ async def _get_dynamic_context() -> str:
169
+ """Fetch dynamic context (core identity + recent memories) from API."""
170
+ try:
171
+ # Get core identity
172
+ client = await _get_api_client()
173
+ identity_response = await client._request("POST", "/memories/search", json={
174
+ "query": "user name location job identity", "k": 10
175
+ })
176
+
177
+ identity_memories = []
178
+ for mem in identity_response.get("results", []):
179
+ tags = mem.get('tags', [])
180
+ if any(tag in tags for tag in {'user_info', 'name', 'location', 'job', 'core_identity', 'identity', 'personal'}):
181
+ identity_memories.append(mem)
182
+
183
+ identity_section = ""
184
+ if identity_memories:
185
+ identity_section = "## 🧬 Core Identity\n"
186
+ for memory in identity_memories[:3]:
187
+ identity_section += f"- {memory['content']}\n"
188
+
189
+ # Get recent context
190
+ recent_response = await client._request("POST", "/memories/search", json={
191
+ "query": "recent context", "k": 3
192
+ })
193
+
194
+ recent_section = ""
195
+ if recent_response.get("results"):
196
+ recent_section = "## πŸ• Recent Context\n"
197
+ for memory in recent_response.get("results", [])[:3]:
198
+ content = memory['content']
199
+ truncated = content[:100] + '...' if len(content) > 100 else content
200
+ recent_section += f"- {truncated}\n"
201
+
202
+ return f"""### 🧠 YOUR BRAIN (Current Working Context)
203
+ {identity_section if identity_section else "*No core identity established yet*"}
204
+ {recent_section if recent_section else "*No recent context*"}
205
+
206
+ ---
207
+
208
+ """
209
+ except Exception as e:
210
+ logger.warning(f"Could not fetch dynamic context: {e}")
211
+ return """### 🧠 YOUR BRAIN (Current Working Context)
212
+ *Context loading failed - API may be unavailable*
213
+
214
+ ---
215
+
216
+ """
217
+
218
+
219
+ # ============================================================================
220
+ # RESOURCES (Documentation that LLMs can read)
221
+ # ============================================================================
222
+
223
+ @mcp.resource("mem-brain://docs/workflow-guide")
224
+ def workflow_guide() -> str:
225
+ """Complete guide to the memory workflow: search strategies, pattern recognition, storage guidelines, and best practices."""
226
+ return """# A-Mem Workflow Guide
227
+
228
+ ## 🎯 CORE DIRECTIVE
229
+ **Synthesize**, don't just retrieve. Connect user's request to their past preferences, habits, and constraints.
230
+
231
+ ## πŸ” MEMORY WORKFLOW
232
+
233
+ **1. SEARCH FIRST & SMART** β€” Before answering personal questions, call `search_memories`.
234
+ - **Formulate specific, natural language queries**, NOT simple keywords.
235
+ - ❌ `query="maga"` (Weak)
236
+ - βœ… `query="Who is Maga and what is his relationship to me?"` (Strong - matches both memory content & link descriptions)
237
+ - Check `related_memories` field β€” these are auto-expanded graph neighbors.
238
+ - Synthesize: If "coffee" result links to "acid reflux", suggest cold brew.
239
+
240
+ **2. PATTERN RECOGNITION** β€” Don't just echo memories back.
241
+ - ❌ "I see a memory that says you like navy"
242
+ - βœ… "This matches the navy aesthetic you've been leaning into"
243
+
244
+ **3. PASSIVE STORAGE** β€” When user reveals preferences, store the **FACT** (not conversation).
245
+ - User: "I think I wanna try that sushi spot" β†’ Store: "User interested in new sushi restaurant"
246
+
247
+ **4. KEEP IT CURRENT** β€” If user contradicts a past memory, use `update_memory`.
248
+
249
+ ## βœ… BEST PRACTICES
250
+
251
+ | DO | DON'T |
252
+ |----|-------|
253
+ | Search before answering personal Q's | Guess without searching |
254
+ | Check `related_memories` field | Ignore graph connections |
255
+ | Store explicit facts | Store vague conversation |
256
+ | Update when info changes | Create duplicates |
257
+ | Synthesize across memories | Just list facts |
258
+
259
+ **Remember:** You're not a database. Connect the dots to provide thoughtful, personalized responses.
260
+ """
261
+
262
+
263
+ @mcp.resource("mem-brain://docs/tool-reference")
264
+ def tool_reference() -> str:
265
+ """Detailed reference for when and how to use each memory tool effectively."""
266
+ return """# Tool Usage Reference
267
+
268
+ ## Core Operations
269
+
270
+ ### `search_memories(query, k=5)`
271
+ **When to Use**: Before answering ANY personal question
272
+ **Critical**: Formulate specific, natural language queries, NOT simple keywords
273
+ - βœ… Good: "Who is Maga and what is their relationship to me?"
274
+ - ❌ Bad: "maga"
275
+
276
+ ### `get_memories(memory_ids)`
277
+ **When to Use**: Need full details for specific IDs identified from search results
278
+
279
+ ### `add_memory(content, tags=[], category="")`
280
+ **When to Use**: User reveals preference/goal/fact
281
+ **Storage Rule**: Store FACTS, not conversation
282
+ - βœ… "User prefers dark mode interfaces"
283
+ - ❌ "You said you like dark mode"
284
+
285
+ ### `update_memory(memory_id, content=..., tags=...)`
286
+ **When to Use**: Information evolves or changes, user contradicts past memory
287
+
288
+ ### `delete_memories(memory_id)`
289
+ **When to Use**: Memory is wrong or user explicitly requests deletion
290
+
291
+ ### `unlink_memories(id1, id2)`
292
+ **When to Use**: Connection no longer relevant or accurate
293
+
294
+ ### `get_stats()`
295
+ **When to Use**: User asks "how much do you remember?" or wants overview
296
+
297
+ ## Graph Intelligence
298
+
299
+ ### `find_path(from_id, to_id)`
300
+ **Purpose**: Explain connections between memories
301
+ **Example**: "How is coffee related to health?" → Shows path: Coffee→Caffeine→Health
302
+
303
+ ### `get_neighborhood(memory_id, hops=2)`
304
+ **Purpose**: Get deep context around a memory
305
+ **Use Case**: Understanding relationships around important memories
306
+ """
307
+
308
+
309
+ @mcp.resource("mem-brain://docs/storage-guidelines")
310
+ def storage_guidelines() -> str:
311
+ """Best practices for storing facts, tagging patterns, and avoiding duplicates."""
312
+ return """# Storage Guidelines
313
+
314
+ ## Write FACTS, not conversation
315
+
316
+ - βœ… "User prefers dark mode interfaces"
317
+ - ❌ "You said you like dark mode"
318
+
319
+ ## Tagging Patterns
320
+
321
+ **Domains**: `health`, `work`, `finance`, `tech`, `food`, `travel`
322
+ **Types**: `preference`, `constraint`, `goal`, `fact`, `event`
323
+ **Priority**: `important`, `routine`, `temporary`
324
+
325
+ ## Avoiding Duplicates
326
+
327
+ 1. If you already searched β†’ check if memory exists before adding
328
+ 2. If similar memory exists β†’ `update_memory` instead
329
+ 3. If you haven't searched β†’ just add it, evolution handles linking
330
+
331
+ ## Changing Preferences
332
+
333
+ | Signal | Action |
334
+ |--------|--------|
335
+ | "I'm trying X", "exploring Y" | ADD new memory (temporary exploration) |
336
+ | "I no longer like X", "I switched to Y" | UPDATE existing memory (permanent change) |
337
+ | Contradictory with equal weight | ADD with temporal context ("as of 2025") |
338
+
339
+ ## Architecture
340
+
341
+ - **Graph Structure**: Memories = nodes, links = edges
342
+ - **Search**: Semantic similarity (70%) + importance/connections (30%)
343
+ - **Auto-linking**: System creates links for narrative/causal connections
344
+ - **User isolation**: Separate database per user
345
+ """
346
+
347
+
348
+ # ============================================================================
349
+ # PROMPTS (Bootstrap Intelligence)
350
+ # ============================================================================
351
+
352
+ @mcp.prompt
353
+ async def setup_personal_memory() -> PromptMessage:
354
+ """Initializes the assistant with the user's identity, recent context, and memory management rules. Run this once at the start of a session."""
355
+ context_section = await _get_dynamic_context()
356
+
357
+ full_instructions = f"""{context_section}{AGENT_INSTRUCTIONS}
358
+
359
+ **Note**: For detailed tool usage, see resource: `mem-brain://docs/tool-reference`
360
+ For storage guidelines, see resource: `mem-brain://docs/storage-guidelines`
361
+ """
362
+
363
+ return PromptMessage(
364
+ role="system",
365
+ content=TextContent(type="text", text=full_instructions)
366
+ )
367
+
368
+
369
+ @mcp.prompt
370
+ async def refresh_context() -> PromptMessage:
371
+ """Refreshes the assistant's context with updated core identity and recent memories. Use when context feels stale."""
372
+ context_section = await _get_dynamic_context()
373
+
374
+ return PromptMessage(
375
+ role="system",
376
+ content=TextContent(
377
+ type="text",
378
+ text=f"""{context_section}
379
+
380
+ **Context refreshed.** Continue using memory tools as before.
381
+ """
382
+ )
383
+ )
384
+
385
+
386
+ # ============================================================================
387
+ # TOOLS (Operations)
388
+ # ============================================================================
389
+
390
+ @mcp.tool()
391
+ async def get_agent_instructions(include_dynamic_context: bool = True) -> str:
392
+ """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."""
393
+ if include_dynamic_context:
394
+ context_section = await _get_dynamic_context()
395
+ else:
396
+ context_section = ""
397
+
398
+ return context_section + AGENT_INSTRUCTIONS
399
+
400
+
401
+ @mcp.tool()
402
+ async def add_memory(
403
+ content: str,
404
+ tags: Optional[List[str]] = None,
405
+ category: Optional[str] = None
406
+ ) -> str:
407
+ """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.
408
+
409
+ 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.
410
+
411
+ Parameters:
412
+ content (str, REQUIRED): The memory content to store. Must be a non-empty string.
413
+ - Cannot be None, empty string, or whitespace-only
414
+ - Example: "User prefers Python over JavaScript"
415
+ - Example: "User prefers dark mode interfaces"
416
+
417
+ tags (list[str] or str, optional): Tags to categorize the memory.
418
+ - Can be None (default), a list of strings, a comma-separated string, or a JSON array string
419
+ - If omitted, the system will auto-generate tags based on content
420
+ - Example: ["coding", "preferences"]
421
+ - Example: "coding,preferences" (comma-separated)
422
+ - Example: '["coding", "preferences"]' (JSON string)
423
+ - Note: The system auto-generates relevant tags, so providing tags is optional
424
+
425
+ category (str, optional): Category name for the memory.
426
+ - Can be None (default) or a non-empty string
427
+ - Example: "interests"
428
+ - Example: "preferences"
429
+
430
+ Returns:
431
+ str: A formatted string with the memory ID and details of the created memory.
432
+
433
+ Common Errors and Solutions:
434
+ - Error: "Tool call arguments for mcp were invalid"
435
+ Solution: Ensure 'content' parameter is provided as a string. Example: add_memory(content="User prefers dark mode")
436
+
437
+ - Error: "The 'content' parameter cannot be empty"
438
+ Solution: Provide non-empty content. Example: add_memory(content="User loves Python programming")
439
+
440
+ - Error: "tags must be a list"
441
+ Solution: Pass tags as a list. Example: add_memory(content="...", tags=["coding"]) not tags="coding"
442
+
443
+ Example workflow:
444
+ 1. search_memories(query="User prefers Python") # Check for existing memories
445
+ 2. If no similar memory found, then: add_memory(content="User prefers Python over JavaScript", tags=["coding", "preferences"])
446
+
447
+ Examples:
448
+ # Basic usage (required parameter only)
449
+ add_memory(content="User prefers dark mode")
450
+
451
+ # With tags
452
+ add_memory(content="User loves Python programming", tags=["coding", "preferences"])
453
+
454
+ # With tags and category
455
+ add_memory(
456
+ content="User loves working with TypeScript",
457
+ tags=["coding", "typescript"],
458
+ category="interests"
459
+ )
460
+
461
+ # Tags as empty list (treated as None)
462
+ add_memory(content="User prefers coffee", tags=[])
463
+ """
464
+ # Validate parameters with detailed error messages
465
+ if content is None:
466
+ raise ToolError(
467
+ "The 'content' parameter is required but was not provided.\n"
468
+ "Example: add_memory(content=\"User prefers dark mode\")\n"
469
+ "Example: add_memory(content=\"User loves Python programming\", tags=[\"coding\"])"
470
+ )
471
+
472
+ if not isinstance(content, str):
473
+ raise ToolError(
474
+ f"The 'content' parameter must be a string, but got {type(content).__name__}.\n"
475
+ f"Received: {repr(content)}\n"
476
+ "Example: add_memory(content=\"User prefers dark mode\")"
477
+ )
478
+
479
+ content_str = str(content).strip()
480
+ if not content_str:
481
+ raise ToolError(
482
+ "The 'content' parameter cannot be empty or whitespace-only.\n"
483
+ "Please provide a non-empty string with actual content.\n"
484
+ "Example: add_memory(content=\"User prefers dark mode\")\n"
485
+ "Example: add_memory(content=\"User loves Python programming\")"
486
+ )
487
+
488
+ try:
489
+ logger.info(f"add_memory called - content length: {len(content_str)}, tags: {tags}, category: {category}")
490
+ logger.debug(f"add_memory full content: {content_str[:100]}...")
491
+
492
+ # Normalize tags: handle various input formats and convert to list of strings
493
+ normalized_tags = None
494
+ if tags is not None:
495
+ if isinstance(tags, list):
496
+ # Validate list contents are strings
497
+ if tags:
498
+ invalid_items = [item for item in tags if not isinstance(item, str)]
499
+ if invalid_items:
500
+ raise ToolError(
501
+ f"The 'tags' parameter must be a list of strings, but found non-string items: {invalid_items}\n"
502
+ f"Example: add_memory(content=\"...\", tags=[\"coding\", \"preferences\"])\n"
503
+ f"Example: add_memory(content=\"...\", tags=[\"personal\", \"pets\"])"
504
+ )
505
+ normalized_tags = tags if tags else None # Empty list becomes None
506
+ elif isinstance(tags, str):
507
+ tags_str = tags.strip()
508
+ if not tags_str:
509
+ normalized_tags = None
510
+ else:
511
+ # Try to parse as JSON array first (e.g., '["tag1", "tag2"]')
512
+ try:
513
+ parsed = json.loads(tags_str)
514
+ if isinstance(parsed, list):
515
+ normalized_tags = [str(item).strip() for item in parsed if str(item).strip()]
516
+ else:
517
+ # If JSON but not a list, treat as single tag
518
+ normalized_tags = [tags_str]
519
+ except (json.JSONDecodeError, ValueError):
520
+ # Not JSON, try comma-separated string
521
+ if ',' in tags_str:
522
+ normalized_tags = [tag.strip() for tag in tags_str.split(',') if tag.strip()]
523
+ else:
524
+ # Single tag string
525
+ normalized_tags = [tags_str]
526
+ else:
527
+ raise ToolError(
528
+ f"The 'tags' parameter must be a list of strings, a comma-separated string, or None, but got {type(tags).__name__}.\n"
529
+ f"Received: {repr(tags)}\n"
530
+ "Example: add_memory(content=\"...\", tags=[\"coding\", \"preferences\"])\n"
531
+ "Example: add_memory(content=\"...\", tags=\"coding,preferences\")\n"
532
+ "Example: add_memory(content=\"...\", tags=None) # or omit tags parameter"
533
+ )
534
+
535
+ # Normalize category: convert empty string to None
536
+ normalized_category = category.strip() if category and isinstance(category, str) and category.strip() else None
537
+
538
+ client = await _get_api_client()
539
+ logger.debug(f"Calling API client.add_memory with content='{content_str[:50]}...', tags={normalized_tags}, category={normalized_category}")
540
+
541
+ result = await client.add_memory(content_str, normalized_tags, normalized_category)
542
+
543
+ logger.info(f"Memory created successfully: {result.get('memory_id', 'unknown')}")
544
+ memory = result.get('memory')
545
+ if memory:
546
+ return f"Memory created: {result.get('memory_id', 'unknown')}\n{_format_memory(memory)}"
547
+ return f"Memory created: {result.get('memory_id', 'unknown')}"
548
+ except httpx.HTTPStatusError as e:
549
+ error_detail = e.response.text if e.response else "Unknown error"
550
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
551
+ if e.response.status_code == 401:
552
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
553
+ elif e.response.status_code == 400:
554
+ raise ToolError(f"Invalid request: {error_detail}")
555
+ raise ToolError(f"Failed to create memory: HTTP {e.response.status_code} - {error_detail}")
556
+ except ToolError:
557
+ raise
558
+ except Exception as e:
559
+ logger.error(f"Unexpected error in add_memory: {e}", exc_info=True)
560
+ raise ToolError(f"Error creating memory: {str(e)}")
561
+
562
+
563
+ @mcp.tool()
564
+ async def search_memories(query: str, k: int = 5) -> str:
565
+ """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.
566
+
567
+ Parameters:
568
+ query (str, REQUIRED): Search query string. Use natural language questions, not keywords.
569
+ - Example: "Who is Rakshith and what did he build?"
570
+ - Example: "What are the user's preferences for programming languages?"
571
+ - Example: "Tell me about memories related to the Dubai presentation"
572
+
573
+ k (int, optional): Number of results to return. Default is 5.
574
+ - Must be between 1 and 100
575
+ - Example: 5 (default)
576
+ - Example: 10
577
+
578
+ Returns:
579
+ str: Formatted search results with memory nodes and relationship edges.
580
+
581
+ Common Errors and Solutions:
582
+ - Error: "Query cannot be empty"
583
+ Solution: Provide a non-empty search query. Example: search_memories(query="What is the user's name?")
584
+
585
+ - Error: "k must be between 1 and 100"
586
+ Solution: Provide k between 1 and 100. Example: search_memories(query="...", k=10)
587
+
588
+ Examples:
589
+ # Basic search
590
+ search_memories(query="Who is Rakshith?")
591
+
592
+ # Search with more results
593
+ search_memories(query="What are the user's programming preferences?", k=10)
594
+
595
+ # Complex query
596
+ search_memories(query="Tell me about memories related to mem-brain and its features")
597
+ """
598
+ # Validate parameters with detailed error messages
599
+ if query is None:
600
+ raise ToolError(
601
+ "The 'query' parameter is required but was not provided.\n"
602
+ "Example: search_memories(query=\"Who is Rakshith?\")\n"
603
+ "Example: search_memories(query=\"What are the user's preferences?\")"
604
+ )
605
+
606
+ if not isinstance(query, str):
607
+ raise ToolError(
608
+ f"The 'query' parameter must be a string, but got {type(query).__name__}.\n"
609
+ f"Received: {repr(query)}\n"
610
+ "Example: search_memories(query=\"Who is Rakshith?\")"
611
+ )
612
+
613
+ query_str = query.strip()
614
+ if not query_str:
615
+ raise ToolError(
616
+ "The 'query' parameter cannot be empty or whitespace-only.\n"
617
+ "Provide a natural language question or search query.\n"
618
+ "Example: search_memories(query=\"Who is Rakshith?\")\n"
619
+ "Example: search_memories(query=\"What are the user's preferences?\")"
620
+ )
621
+
622
+ if not isinstance(k, int):
623
+ raise ToolError(
624
+ f"The 'k' parameter must be an integer, but got {type(k).__name__}.\n"
625
+ f"Received: {repr(k)}\n"
626
+ "Example: search_memories(query=\"...\", k=10)"
627
+ )
628
+
629
+ if not (1 <= k <= 100):
630
+ raise ToolError(
631
+ f"The 'k' parameter must be between 1 and 100, but got {k}.\n"
632
+ "Example: search_memories(query=\"...\", k=5)\n"
633
+ "Example: search_memories(query=\"...\", k=10)"
634
+ )
635
+
636
+ try:
637
+ logger.info(f"search_memories called - query length: {len(query_str)}, k: {k}")
638
+ client = await _get_api_client()
639
+ result = await client.search_memories(query_str, k)
640
+ return f"Found {result.get('count', 0)} results:\n{_format_search_results(result.get('results', []))}"
641
+ except httpx.HTTPStatusError as e:
642
+ error_detail = e.response.text if e.response else "Unknown error"
643
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
644
+ if e.response.status_code == 401:
645
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
646
+ raise ToolError(f"Failed to search memories: HTTP {e.response.status_code} - {error_detail}")
647
+ except ToolError:
648
+ raise
649
+ except Exception as e:
650
+ logger.error(f"Unexpected error in search_memories: {e}", exc_info=True)
651
+ raise ToolError(f"Error searching memories: {str(e)}")
652
+
653
+
654
+ @mcp.tool()
655
+ async def get_memories(memory_ids: List[str]) -> str:
656
+ """Retrieve one or more memories by ID. Use this when you need full details for specific memories identified from search results.
657
+
658
+ Parameters:
659
+ memory_ids (list[str], REQUIRED): List of memory IDs to retrieve. Must be a non-empty list.
660
+ - Example: ["480c1f76-bcdf-4491-8781-24510db992e3"]
661
+ - Example: ["480c1f76-...", "300d9716-...", "6fb6b23f-..."]
662
+ - Get memory IDs from search_memories() results
663
+
664
+ Returns:
665
+ str: Formatted details of the retrieved memories.
666
+
667
+ Common Errors and Solutions:
668
+ - Error: "memory_ids cannot be empty"
669
+ Solution: Provide a list with at least one memory ID. Example: get_memories(memory_ids=["480c1f76-..."])
670
+
671
+ - Error: "Memory IDs cannot be empty"
672
+ Solution: Ensure all IDs in the list are non-empty strings. Example: get_memories(memory_ids=["480c1f76-..."])
673
+
674
+ - Error: "memory_ids must be a list"
675
+ Solution: Pass memory_ids as a list. Example: get_memories(memory_ids=["..."]) not memory_ids="..."
676
+
677
+ Examples:
678
+ # Get single memory
679
+ get_memories(memory_ids=["480c1f76-bcdf-4491-8781-24510db992e3"])
680
+
681
+ # Get multiple memories
682
+ get_memories(memory_ids=["480c1f76-...", "300d9716-...", "6fb6b23f-..."])
683
+ """
684
+ # Validate parameters with detailed error messages
685
+ if memory_ids is None:
686
+ raise ToolError(
687
+ "The 'memory_ids' parameter is required but was not provided.\n"
688
+ "Example: get_memories(memory_ids=[\"480c1f76-bcdf-4491-8781-24510db992e3\"])\n"
689
+ "Example: get_memories(memory_ids=[\"480c1f76-...\", \"300d9716-...\"])"
690
+ )
691
+
692
+ if not isinstance(memory_ids, list):
693
+ raise ToolError(
694
+ f"The 'memory_ids' parameter must be a list of strings, but got {type(memory_ids).__name__}.\n"
695
+ f"Received: {repr(memory_ids)}\n"
696
+ "Example: get_memories(memory_ids=[\"480c1f76-...\"])"
697
+ )
698
+
699
+ if not memory_ids:
700
+ raise ToolError(
701
+ "The 'memory_ids' parameter cannot be an empty list.\n"
702
+ "Provide at least one memory ID.\n"
703
+ "Example: get_memories(memory_ids=[\"480c1f76-bcdf-4491-8781-24510db992e3\"])"
704
+ )
705
+
706
+ # Validate each memory ID in the list
707
+ validated_ids = []
708
+ for i, memory_id in enumerate(memory_ids):
709
+ if memory_id is None:
710
+ raise ToolError(
711
+ f"Memory ID at index {i} is None. All memory IDs must be non-empty strings.\n"
712
+ "Example: get_memories(memory_ids=[\"480c1f76-...\"])"
713
+ )
714
+ if not isinstance(memory_id, str):
715
+ raise ToolError(
716
+ f"Memory ID at index {i} must be a string, but got {type(memory_id).__name__}.\n"
717
+ f"Received: {repr(memory_id)}\n"
718
+ "Example: get_memories(memory_ids=[\"480c1f76-...\"])"
719
+ )
720
+ memory_id_str = memory_id.strip()
721
+ if not memory_id_str:
722
+ raise ToolError(
723
+ f"Memory ID at index {i} cannot be empty or whitespace-only.\n"
724
+ "Get memory IDs from search_memories() or get_memories() results.\n"
725
+ "Example: get_memories(memory_ids=[\"480c1f76-bcdf-4491-8781-24510db992e3\"])"
726
+ )
727
+ validated_ids.append(memory_id_str)
728
+
729
+ try:
730
+ logger.info(f"get_memories called - count: {len(validated_ids)}")
731
+ client = await _get_api_client()
732
+ result = await client.get_memories(validated_ids)
733
+ memories = result.get("memories", [])
734
+ return f"Retrieved {len(memories)} memories:\n{_format_memories_list(memories)}"
735
+ except httpx.HTTPStatusError as e:
736
+ error_detail = e.response.text if e.response else "Unknown error"
737
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
738
+ if e.response.status_code == 401:
739
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
740
+ elif e.response.status_code == 404:
741
+ raise ToolError(f"One or more memories not found.\nVerify the memory IDs are correct by searching for them first.")
742
+ raise ToolError(f"Failed to get memories: HTTP {e.response.status_code} - {error_detail}")
743
+ except ToolError:
744
+ raise
745
+ except Exception as e:
746
+ logger.error(f"Unexpected error in get_memories: {e}", exc_info=True)
747
+ raise ToolError(f"Error getting memories: {str(e)}")
748
+
749
+
750
+ @mcp.tool()
751
+ async def update_memory(
752
+ memory_id: str,
753
+ content: Optional[str] = None,
754
+ tags: Optional[List[str]] = None
755
+ ) -> str:
756
+ """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.
757
+
758
+ Parameters:
759
+ memory_id (str, REQUIRED): The ID of the memory to update. Must be a non-empty string.
760
+ - Example: "480c1f76-bcdf-4491-8781-24510db992e3"
761
+ - Get memory IDs from search_memories() or get_memories() results
762
+
763
+ content (str, optional): New content for the memory.
764
+ - Can be None (to keep existing content) or a non-empty string
765
+ - If provided, must not be empty or whitespace-only
766
+ - Example: "User no longer likes TypeScript, prefers Python"
767
+
768
+ tags (list[str] or str, optional): New tags for the memory.
769
+ - Can be None (to keep existing tags), a list of strings, a comma-separated string, or a JSON array string
770
+ - If provided, replaces existing tags
771
+ - Example: ["coding", "python"]
772
+ - Example: "coding,python" (comma-separated)
773
+ - Example: '["coding", "python"]' (JSON string)
774
+ - Note: The system can auto-generate tags if you omit this parameter
775
+
776
+ Returns:
777
+ str: A formatted string with the updated memory details.
778
+
779
+ Common Errors and Solutions:
780
+ - Error: "Tool call arguments for mcp were invalid"
781
+ Solution: Ensure 'memory_id' parameter is provided as a string. Example: update_memory(memory_id="...")
782
+
783
+ - Error: "memory_id cannot be empty"
784
+ Solution: Provide a valid memory ID from search results. Example: update_memory(memory_id="480c1f76-...")
785
+
786
+ - Error: "At least one of 'content' or 'tags' must be provided"
787
+ Solution: Provide content or tags to update. Example: update_memory(memory_id="...", content="New content")
788
+
789
+ Examples:
790
+ # Update content only
791
+ update_memory(memory_id="480c1f76-...", content="User prefers Python over JavaScript")
792
+
793
+ # Update tags only
794
+ update_memory(memory_id="480c1f76-...", tags=["coding", "preferences"])
795
+
796
+ # Update both content and tags
797
+ update_memory(
798
+ memory_id="480c1f76-...",
799
+ content="User no longer likes TypeScript",
800
+ tags=["coding", "python"]
801
+ )
802
+ """
803
+ # Validate parameters with detailed error messages
804
+ if memory_id is None:
805
+ raise ToolError(
806
+ "The 'memory_id' parameter is required but was not provided.\n"
807
+ "Get memory IDs from search_memories() or get_memories() results.\n"
808
+ "Example: update_memory(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\", content=\"New content\")"
809
+ )
810
+
811
+ if not isinstance(memory_id, str):
812
+ raise ToolError(
813
+ f"The 'memory_id' parameter must be a string, but got {type(memory_id).__name__}.\n"
814
+ f"Received: {repr(memory_id)}\n"
815
+ "Example: update_memory(memory_id=\"480c1f76-...\", content=\"New content\")"
816
+ )
817
+
818
+ memory_id_str = memory_id.strip()
819
+ if not memory_id_str:
820
+ raise ToolError(
821
+ "The 'memory_id' parameter cannot be empty or whitespace-only.\n"
822
+ "Get memory IDs from search_memories() or get_memories() results.\n"
823
+ "Example: update_memory(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\", content=\"New content\")"
824
+ )
825
+
826
+ # Validate that at least one update parameter is provided
827
+ if content is None and tags is None:
828
+ raise ToolError(
829
+ "At least one of 'content' or 'tags' must be provided to update the memory.\n"
830
+ "Example: update_memory(memory_id=\"...\", content=\"New content\")\n"
831
+ "Example: update_memory(memory_id=\"...\", tags=[\"new\", \"tags\"])"
832
+ )
833
+
834
+ # Validate content if provided
835
+ if content is not None:
836
+ if not isinstance(content, str):
837
+ raise ToolError(
838
+ f"The 'content' parameter must be a string or None, but got {type(content).__name__}.\n"
839
+ f"Received: {repr(content)}\n"
840
+ "Example: update_memory(memory_id=\"...\", content=\"New content\")"
841
+ )
842
+ content_str = str(content).strip()
843
+ if not content_str:
844
+ raise ToolError(
845
+ "The 'content' parameter cannot be empty or whitespace-only.\n"
846
+ "Provide a non-empty string or omit the parameter to keep existing content.\n"
847
+ "Example: update_memory(memory_id=\"...\", content=\"New content\")"
848
+ )
849
+ else:
850
+ content_str = None
851
+
852
+ # Validate tags if provided - handle various input formats
853
+ normalized_tags = None
854
+ if tags is not None:
855
+ if isinstance(tags, list):
856
+ # Validate list contents are strings
857
+ if tags:
858
+ invalid_items = [item for item in tags if not isinstance(item, str)]
859
+ if invalid_items:
860
+ raise ToolError(
861
+ f"The 'tags' parameter must be a list of strings, but found non-string items: {invalid_items}\n"
862
+ "Example: update_memory(memory_id=\"...\", tags=[\"coding\", \"preferences\"])\n"
863
+ "Example: update_memory(memory_id=\"...\", tags=None) # or omit tags parameter"
864
+ )
865
+ normalized_tags = tags if tags else None # Empty list becomes None
866
+ elif isinstance(tags, str):
867
+ tags_str = tags.strip()
868
+ if not tags_str:
869
+ normalized_tags = None
870
+ else:
871
+ # Try to parse as JSON array first (e.g., '["tag1", "tag2"]')
872
+ try:
873
+ parsed = json.loads(tags_str)
874
+ if isinstance(parsed, list):
875
+ normalized_tags = [str(item).strip() for item in parsed if str(item).strip()]
876
+ else:
877
+ # If JSON but not a list, treat as single tag
878
+ normalized_tags = [tags_str]
879
+ except (json.JSONDecodeError, ValueError):
880
+ # Not JSON, try comma-separated string
881
+ if ',' in tags_str:
882
+ normalized_tags = [tag.strip() for tag in tags_str.split(',') if tag.strip()]
883
+ else:
884
+ # Single tag string
885
+ normalized_tags = [tags_str]
886
+ else:
887
+ raise ToolError(
888
+ f"The 'tags' parameter must be a list of strings, a comma-separated string, or None, but got {type(tags).__name__}.\n"
889
+ f"Received: {repr(tags)}\n"
890
+ "Example: update_memory(memory_id=\"...\", tags=[\"coding\", \"preferences\"])\n"
891
+ "Example: update_memory(memory_id=\"...\", tags=\"coding,preferences\")\n"
892
+ "Example: update_memory(memory_id=\"...\", tags=None) # or omit tags parameter"
893
+ )
894
+
895
+ try:
896
+ logger.info(f"update_memory called - memory_id: {memory_id_str}, content length: {len(content_str) if content_str else 0}, tags: {normalized_tags}")
897
+
898
+ client = await _get_api_client()
899
+ result = await client.update_memory(memory_id_str, content_str, normalized_tags)
900
+ memory = result.get('memory')
901
+ if memory:
902
+ return f"Memory updated:\n{_format_memory(memory)}"
903
+ return f"Memory {memory_id_str} updated"
904
+ except httpx.HTTPStatusError as e:
905
+ error_detail = e.response.text if e.response else "Unknown error"
906
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
907
+ if e.response.status_code == 401:
908
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
909
+ elif e.response.status_code == 400:
910
+ raise ToolError(f"Invalid request: {error_detail}\nExample: update_memory(memory_id=\"...\", content=\"New content\")")
911
+ elif e.response.status_code == 404:
912
+ raise ToolError(f"Memory not found: {memory_id_str}\nVerify the memory_id is correct by searching for it first.")
913
+ raise ToolError(f"Failed to update memory: HTTP {e.response.status_code} - {error_detail}")
914
+ except ToolError:
915
+ raise
916
+ except Exception as e:
917
+ logger.error(f"Unexpected error in update_memory: {e}", exc_info=True)
918
+ raise ToolError(f"Error updating memory: {str(e)}")
919
+
920
+
921
+ @mcp.tool()
922
+ async def delete_memories(
923
+ memory_id: Optional[str] = None,
924
+ tags: Optional[str] = None,
925
+ category: Optional[str] = None
926
+ ) -> str:
927
+ """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.
928
+
929
+ Parameters:
930
+ memory_id (str, optional): Specific memory ID to delete. Takes precedence over filters.
931
+ - Example: "480c1f76-bcdf-4491-8781-24510db992e3"
932
+ - Get memory IDs from search_memories() or get_memories() results
933
+
934
+ tags (str, optional): Comma-separated tags for filter-based deletion.
935
+ - Example: "coding,preferences"
936
+ - Example: "personal,pets"
937
+ - Only used if memory_id is not provided
938
+
939
+ category (str, optional): Category name for filter-based deletion.
940
+ - Example: "interests"
941
+ - Example: "preferences"
942
+ - Only used if memory_id is not provided
943
+
944
+ Returns:
945
+ str: A message indicating how many memories were deleted and their IDs.
946
+
947
+ Common Errors and Solutions:
948
+ - Error: "At least one parameter must be provided"
949
+ Solution: Provide memory_id, tags, or category. Example: delete_memories(memory_id="...")
950
+
951
+ - Error: "memory_id cannot be empty"
952
+ Solution: Provide a valid memory ID or omit the parameter. Example: delete_memories(memory_id="480c1f76-...")
953
+
954
+ Examples:
955
+ # Delete by memory ID
956
+ delete_memories(memory_id="480c1f76-bcdf-4491-8781-24510db992e3")
957
+
958
+ # Delete by tags
959
+ delete_memories(tags="coding,preferences")
960
+
961
+ # Delete by category
962
+ delete_memories(category="interests")
963
+ """
964
+ # Validate that at least one parameter is provided
965
+ if memory_id is None and tags is None and category is None:
966
+ raise ToolError(
967
+ "At least one parameter (memory_id, tags, or category) must be provided to delete memories.\n"
968
+ "Example: delete_memories(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")\n"
969
+ "Example: delete_memories(tags=\"coding,preferences\")\n"
970
+ "Example: delete_memories(category=\"interests\")"
971
+ )
972
+
973
+ # Validate memory_id if provided
974
+ if memory_id is not None:
975
+ if not isinstance(memory_id, str):
976
+ raise ToolError(
977
+ f"The 'memory_id' parameter must be a string or None, but got {type(memory_id).__name__}.\n"
978
+ f"Received: {repr(memory_id)}\n"
979
+ "Example: delete_memories(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")"
980
+ )
981
+ memory_id_str = memory_id.strip()
982
+ if not memory_id_str:
983
+ raise ToolError(
984
+ "The 'memory_id' parameter cannot be empty or whitespace-only.\n"
985
+ "Get memory IDs from search_memories() or get_memories() results.\n"
986
+ "Example: delete_memories(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")"
987
+ )
988
+ else:
989
+ memory_id_str = None
990
+
991
+ # Validate tags if provided
992
+ if tags is not None:
993
+ if not isinstance(tags, str):
994
+ raise ToolError(
995
+ f"The 'tags' parameter must be a string or None, but got {type(tags).__name__}.\n"
996
+ f"Received: {repr(tags)}\n"
997
+ "Example: delete_memories(tags=\"coding,preferences\")"
998
+ )
999
+ tags_str = tags.strip()
1000
+ if not tags_str:
1001
+ raise ToolError(
1002
+ "The 'tags' parameter cannot be empty or whitespace-only.\n"
1003
+ "Provide comma-separated tags or omit the parameter.\n"
1004
+ "Example: delete_memories(tags=\"coding,preferences\")"
1005
+ )
1006
+ else:
1007
+ tags_str = None
1008
+
1009
+ # Validate category if provided
1010
+ if category is not None:
1011
+ if not isinstance(category, str):
1012
+ raise ToolError(
1013
+ f"The 'category' parameter must be a string or None, but got {type(category).__name__}.\n"
1014
+ f"Received: {repr(category)}\n"
1015
+ "Example: delete_memories(category=\"interests\")"
1016
+ )
1017
+ category_str = category.strip()
1018
+ if not category_str:
1019
+ raise ToolError(
1020
+ "The 'category' parameter cannot be empty or whitespace-only.\n"
1021
+ "Provide a category name or omit the parameter.\n"
1022
+ "Example: delete_memories(category=\"interests\")"
1023
+ )
1024
+ else:
1025
+ category_str = None
1026
+
1027
+ try:
1028
+ logger.info(f"delete_memories called - memory_id: {memory_id_str}, tags: {tags_str}, category: {category_str}")
1029
+ client = await _get_api_client()
1030
+ result = await client.delete_memories(memory_id_str, tags_str, category_str)
1031
+ deleted_ids = result.get('memory_ids', [])[:10]
1032
+ return f"Deleted {result.get('deleted_count', 0)} memories. IDs: {', '.join(deleted_ids)}"
1033
+ except httpx.HTTPStatusError as e:
1034
+ error_detail = e.response.text if e.response else "Unknown error"
1035
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
1036
+ if e.response.status_code == 401:
1037
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
1038
+ elif e.response.status_code == 404:
1039
+ raise ToolError(f"Memory not found: {memory_id_str}\nVerify the memory_id is correct by searching for it first.")
1040
+ raise ToolError(f"Failed to delete memories: HTTP {e.response.status_code} - {error_detail}")
1041
+ except ToolError:
1042
+ raise
1043
+ except Exception as e:
1044
+ logger.error(f"Unexpected error in delete_memories: {e}", exc_info=True)
1045
+ raise ToolError(f"Error deleting memories: {str(e)}")
1046
+
1047
+
1048
+ @mcp.tool()
1049
+ async def unlink_memories(memory_id_1: str, memory_id_2: str) -> str:
1050
+ """Remove link between two memories when the connection is no longer relevant or accurate.
1051
+
1052
+ Parameters:
1053
+ memory_id_1 (str, REQUIRED): First memory ID in the link to remove.
1054
+ - Example: "480c1f76-bcdf-4491-8781-24510db992e3"
1055
+ - Get memory IDs from search_memories() or get_memories() results
1056
+
1057
+ memory_id_2 (str, REQUIRED): Second memory ID in the link to remove.
1058
+ - Example: "300d9716-a3a6-44d3-b0f4-b28002a65da8"
1059
+ - Get memory IDs from search_memories() or get_memories() results
1060
+
1061
+ Returns:
1062
+ str: Confirmation message that the memories were unlinked.
1063
+
1064
+ Common Errors and Solutions:
1065
+ - Error: "memory_id_1 cannot be empty"
1066
+ Solution: Provide a valid memory ID. Example: unlink_memories(memory_id_1="480c1f76-...", memory_id_2="300d9716-...")
1067
+
1068
+ - Error: "memory_id_2 cannot be empty"
1069
+ Solution: Provide a valid memory ID. Example: unlink_memories(memory_id_1="480c1f76-...", memory_id_2="300d9716-...")
1070
+
1071
+ Examples:
1072
+ # Unlink two memories
1073
+ unlink_memories(
1074
+ memory_id_1="480c1f76-bcdf-4491-8781-24510db992e3",
1075
+ memory_id_2="300d9716-a3a6-44d3-b0f4-b28002a65da8"
1076
+ )
1077
+ """
1078
+ # Validate parameters with detailed error messages
1079
+ if memory_id_1 is None:
1080
+ raise ToolError(
1081
+ "The 'memory_id_1' parameter is required but was not provided.\n"
1082
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1083
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-...\")"
1084
+ )
1085
+
1086
+ if not isinstance(memory_id_1, str):
1087
+ raise ToolError(
1088
+ f"The 'memory_id_1' parameter must be a string, but got {type(memory_id_1).__name__}.\n"
1089
+ f"Received: {repr(memory_id_1)}\n"
1090
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-...\")"
1091
+ )
1092
+
1093
+ memory_id_1_str = memory_id_1.strip()
1094
+ if not memory_id_1_str:
1095
+ raise ToolError(
1096
+ "The 'memory_id_1' parameter cannot be empty or whitespace-only.\n"
1097
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1098
+ "Example: unlink_memories(memory_id_1=\"480c1f76-bcdf-4491-8781-24510db992e3\", memory_id_2=\"300d9716-...\")"
1099
+ )
1100
+
1101
+ if memory_id_2 is None:
1102
+ raise ToolError(
1103
+ "The 'memory_id_2' parameter is required but was not provided.\n"
1104
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1105
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-...\")"
1106
+ )
1107
+
1108
+ if not isinstance(memory_id_2, str):
1109
+ raise ToolError(
1110
+ f"The 'memory_id_2' parameter must be a string, but got {type(memory_id_2).__name__}.\n"
1111
+ f"Received: {repr(memory_id_2)}\n"
1112
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-...\")"
1113
+ )
1114
+
1115
+ memory_id_2_str = memory_id_2.strip()
1116
+ if not memory_id_2_str:
1117
+ raise ToolError(
1118
+ "The 'memory_id_2' parameter cannot be empty or whitespace-only.\n"
1119
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1120
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-a3a6-44d3-b0f4-b28002a65da8\")"
1121
+ )
1122
+
1123
+ # Ensure the IDs are different
1124
+ if memory_id_1_str == memory_id_2_str:
1125
+ raise ToolError(
1126
+ "memory_id_1 and memory_id_2 must be different.\n"
1127
+ "You cannot unlink a memory from itself.\n"
1128
+ "Example: unlink_memories(memory_id_1=\"480c1f76-...\", memory_id_2=\"300d9716-...\")"
1129
+ )
1130
+
1131
+ try:
1132
+ logger.info(f"unlink_memories called - memory_id_1: {memory_id_1_str}, memory_id_2: {memory_id_2_str}")
1133
+ client = await _get_api_client()
1134
+ result = await client.unlink_memories(memory_id_1_str, memory_id_2_str)
1135
+ return result.get("message", "Memories unlinked")
1136
+ except httpx.HTTPStatusError as e:
1137
+ error_detail = e.response.text if e.response else "Unknown error"
1138
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
1139
+ if e.response.status_code == 401:
1140
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
1141
+ elif e.response.status_code == 404:
1142
+ raise ToolError(f"One or both memories not found.\nVerify the memory IDs are correct by searching for them first.")
1143
+ raise ToolError(f"Failed to unlink memories: HTTP {e.response.status_code} - {error_detail}")
1144
+ except ToolError:
1145
+ raise
1146
+ except Exception as e:
1147
+ logger.error(f"Unexpected error in unlink_memories: {e}", exc_info=True)
1148
+ raise ToolError(f"Error unlinking memories: {str(e)}")
1149
+
1150
+
1151
+ @mcp.tool()
1152
+ async def get_stats(_placeholder: Optional[bool] = None) -> str:
1153
+ """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.
1154
+
1155
+ Parameters:
1156
+ _placeholder (bool, optional): Placeholder parameter for OpenCode compatibility. This parameter is ignored and can be omitted or set to any value. The function takes no actual parameters.
1157
+ - This is a workaround for MCP clients that incorrectly require a parameter for parameterless tools
1158
+ - Can be safely omitted or set to None/True/False
1159
+ - Example: get_stats() or get_stats(_placeholder=True)
1160
+
1161
+ Returns:
1162
+ str: Formatted statistics including total memories, links, and top tags.
1163
+
1164
+ Examples:
1165
+ # Get statistics (preferred - no parameters needed)
1166
+ get_stats()
1167
+
1168
+ # Get statistics (OpenCode workaround - parameter is ignored)
1169
+ get_stats(_placeholder=True)
1170
+ """
1171
+ # _placeholder parameter is ignored - this is a workaround for OpenCode compatibility
1172
+ # The function actually takes no parameters, but some MCP clients incorrectly require one
1173
+ try:
1174
+ logger.info("get_stats called")
1175
+ logger.debug(f"get_stats called with _placeholder={_placeholder} (ignored)")
1176
+ client = await _get_api_client()
1177
+ logger.debug(f"API client initialized with base_url: {client.base_url}")
1178
+ result = await client.get_stats()
1179
+ logger.debug(f"get_stats result received: {list(result.keys()) if isinstance(result, dict) else 'N/A'}")
1180
+ top_tags = ', '.join([f"{tag}({count})" for tag, count in result.get('top_tags', [])[:10]])
1181
+ return f"""Memory System Statistics:
1182
+ Total Memories: {result.get('total_memories', 0)}
1183
+ Total Links: {result.get('total_links', 0)}
1184
+ Average Links per Memory: {result.get('avg_links_per_memory', 0):.2f}
1185
+ Top Tags: {top_tags}"""
1186
+ except httpx.HTTPStatusError as e:
1187
+ error_detail = e.response.text if e.response else "Unknown error"
1188
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
1189
+ if e.response.status_code == 401:
1190
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
1191
+ raise ToolError(f"Failed to get stats: HTTP {e.response.status_code} - {error_detail}")
1192
+ except ToolError:
1193
+ raise
1194
+ except Exception as e:
1195
+ logger.error(f"Unexpected error in get_stats: {e}", exc_info=True)
1196
+ raise ToolError(f"Error getting stats: {str(e)}")
1197
+
1198
+
1199
+ @mcp.tool()
1200
+ async def find_path(from_id: str, to_id: str) -> str:
1201
+ """Find shortest path between two memories in the memory graph. Use this to explain connections between seemingly unrelated memories.
1202
+
1203
+ Parameters:
1204
+ from_id (str, REQUIRED): Source memory ID to start the path from.
1205
+ - Example: "480c1f76-bcdf-4491-8781-24510db992e3"
1206
+ - Get memory IDs from search_memories() or get_memories() results
1207
+
1208
+ to_id (str, REQUIRED): Target memory ID to find path to.
1209
+ - Example: "300d9716-a3a6-44d3-b0f4-b28002a65da8"
1210
+ - Get memory IDs from search_memories() or get_memories() results
1211
+
1212
+ Returns:
1213
+ str: The shortest path between the two memories, or a message if no path exists.
1214
+
1215
+ Common Errors and Solutions:
1216
+ - Error: "from_id cannot be empty"
1217
+ Solution: Provide a valid memory ID. Example: find_path(from_id="480c1f76-...", to_id="300d9716-...")
1218
+
1219
+ - Error: "to_id cannot be empty"
1220
+ Solution: Provide a valid memory ID. Example: find_path(from_id="480c1f76-...", to_id="300d9716-...")
1221
+
1222
+ Examples:
1223
+ # Find path between two memories
1224
+ find_path(
1225
+ from_id="480c1f76-bcdf-4491-8781-24510db992e3",
1226
+ to_id="300d9716-a3a6-44d3-b0f4-b28002a65da8"
1227
+ )
1228
+ """
1229
+ # Validate parameters with detailed error messages
1230
+ if from_id is None:
1231
+ raise ToolError(
1232
+ "The 'from_id' parameter is required but was not provided.\n"
1233
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1234
+ "Example: find_path(from_id=\"480c1f76-...\", to_id=\"300d9716-...\")"
1235
+ )
1236
+
1237
+ if not isinstance(from_id, str):
1238
+ raise ToolError(
1239
+ f"The 'from_id' parameter must be a string, but got {type(from_id).__name__}.\n"
1240
+ f"Received: {repr(from_id)}\n"
1241
+ "Example: find_path(from_id=\"480c1f76-...\", to_id=\"300d9716-...\")"
1242
+ )
1243
+
1244
+ from_id_str = from_id.strip()
1245
+ if not from_id_str:
1246
+ raise ToolError(
1247
+ "The 'from_id' parameter cannot be empty or whitespace-only.\n"
1248
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1249
+ "Example: find_path(from_id=\"480c1f76-bcdf-4491-8781-24510db992e3\", to_id=\"300d9716-...\")"
1250
+ )
1251
+
1252
+ if to_id is None:
1253
+ raise ToolError(
1254
+ "The 'to_id' parameter is required but was not provided.\n"
1255
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1256
+ "Example: find_path(from_id=\"480c1f76-...\", to_id=\"300d9716-...\")"
1257
+ )
1258
+
1259
+ if not isinstance(to_id, str):
1260
+ raise ToolError(
1261
+ f"The 'to_id' parameter must be a string, but got {type(to_id).__name__}.\n"
1262
+ f"Received: {repr(to_id)}\n"
1263
+ "Example: find_path(from_id=\"480c1f76-...\", to_id=\"300d9716-...\")"
1264
+ )
1265
+
1266
+ to_id_str = to_id.strip()
1267
+ if not to_id_str:
1268
+ raise ToolError(
1269
+ "The 'to_id' parameter cannot be empty or whitespace-only.\n"
1270
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1271
+ "Example: find_path(from_id=\"480c1f76-...\", to_id=\"300d9716-a3a6-44d3-b0f4-b28002a65da8\")"
1272
+ )
1273
+
1274
+ try:
1275
+ logger.info(f"find_path called - from_id: {from_id_str}, to_id: {to_id_str}")
1276
+ client = await _get_api_client()
1277
+ result = await client.find_path(from_id_str, to_id_str)
1278
+ if result.get("status") == "success":
1279
+ path_text = f"Path found (length: {result.get('length', 0)}):\n"
1280
+ for mem in result.get("memories", []):
1281
+ path_text += f" - {mem.get('id', 'unknown')}: {mem.get('content', '')[:100]}\n"
1282
+ return path_text
1283
+ return result.get("message", "No path found")
1284
+ except httpx.HTTPStatusError as e:
1285
+ error_detail = e.response.text if e.response else "Unknown error"
1286
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
1287
+ if e.response.status_code == 401:
1288
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
1289
+ elif e.response.status_code == 404:
1290
+ raise ToolError(f"One or both memories not found.\nVerify the memory IDs are correct by searching for them first.")
1291
+ raise ToolError(f"Failed to find path: HTTP {e.response.status_code} - {error_detail}")
1292
+ except ToolError:
1293
+ raise
1294
+ except Exception as e:
1295
+ logger.error(f"Unexpected error in find_path: {e}", exc_info=True)
1296
+ raise ToolError(f"Error finding path: {str(e)}")
1297
+
1298
+
1299
+ @mcp.tool()
1300
+ async def get_neighborhood(memory_id: str, hops: int = 2) -> str:
1301
+ """Get all memories within N hops of a given memory. Use this for deep context and understanding relationships around important memories.
1302
+
1303
+ Parameters:
1304
+ memory_id (str, REQUIRED): Center memory ID to get neighborhood around.
1305
+ - Example: "480c1f76-bcdf-4491-8781-24510db992e3"
1306
+ - Get memory IDs from search_memories() or get_memories() results
1307
+
1308
+ hops (int, optional): Number of hops to traverse. Default is 2.
1309
+ - Must be between 1 and 5
1310
+ - 1 hop = direct connections only
1311
+ - 2 hops = direct connections + their connections
1312
+ - Example: 2 (default)
1313
+ - Example: 3
1314
+
1315
+ Returns:
1316
+ str: Formatted list of memories in the neighborhood with their hop distances.
1317
+
1318
+ Common Errors and Solutions:
1319
+ - Error: "memory_id cannot be empty"
1320
+ Solution: Provide a valid memory ID. Example: get_neighborhood(memory_id="480c1f76-...")
1321
+
1322
+ - Error: "hops must be between 1 and 5"
1323
+ Solution: Provide hops between 1 and 5. Example: get_neighborhood(memory_id="...", hops=3)
1324
+
1325
+ Examples:
1326
+ # Get neighborhood with default 2 hops
1327
+ get_neighborhood(memory_id="480c1f76-bcdf-4491-8781-24510db992e3")
1328
+
1329
+ # Get neighborhood with 3 hops
1330
+ get_neighborhood(memory_id="480c1f76-bcdf-4491-8781-24510db992e3", hops=3)
1331
+
1332
+ # Get direct connections only (1 hop)
1333
+ get_neighborhood(memory_id="480c1f76-bcdf-4491-8781-24510db992e3", hops=1)
1334
+ """
1335
+ # Validate parameters with detailed error messages
1336
+ if memory_id is None:
1337
+ raise ToolError(
1338
+ "The 'memory_id' parameter is required but was not provided.\n"
1339
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1340
+ "Example: get_neighborhood(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")"
1341
+ )
1342
+
1343
+ if not isinstance(memory_id, str):
1344
+ raise ToolError(
1345
+ f"The 'memory_id' parameter must be a string, but got {type(memory_id).__name__}.\n"
1346
+ f"Received: {repr(memory_id)}\n"
1347
+ "Example: get_neighborhood(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")"
1348
+ )
1349
+
1350
+ memory_id_str = memory_id.strip()
1351
+ if not memory_id_str:
1352
+ raise ToolError(
1353
+ "The 'memory_id' parameter cannot be empty or whitespace-only.\n"
1354
+ "Get memory IDs from search_memories() or get_memories() results.\n"
1355
+ "Example: get_neighborhood(memory_id=\"480c1f76-bcdf-4491-8781-24510db992e3\")"
1356
+ )
1357
+
1358
+ if not isinstance(hops, int):
1359
+ raise ToolError(
1360
+ f"The 'hops' parameter must be an integer, but got {type(hops).__name__}.\n"
1361
+ f"Received: {repr(hops)}\n"
1362
+ "Example: get_neighborhood(memory_id=\"...\", hops=2)"
1363
+ )
1364
+
1365
+ if not (1 <= hops <= 5):
1366
+ raise ToolError(
1367
+ f"The 'hops' parameter must be between 1 and 5, but got {hops}.\n"
1368
+ "Example: get_neighborhood(memory_id=\"...\", hops=2)\n"
1369
+ "Example: get_neighborhood(memory_id=\"...\", hops=3)"
1370
+ )
1371
+
1372
+ try:
1373
+ logger.info(f"get_neighborhood called - memory_id: {memory_id_str}, hops: {hops}")
1374
+ client = await _get_api_client()
1375
+ result = await client.get_neighborhood(memory_id_str, hops)
1376
+ neighborhood_text = f"Neighborhood (hops={result.get('hops', 2)}, total={result.get('total_in_neighborhood', 0)}):\n"
1377
+ for mem in result.get("neighborhood", []):
1378
+ hop_dist = mem.get("hop_distance", 0)
1379
+ is_center = " (center)" if mem.get("is_center") else ""
1380
+ neighborhood_text += f" [{hop_dist}]{is_center} {mem.get('id', 'unknown')}: {mem.get('content', '')[:100]}\n"
1381
+ return neighborhood_text
1382
+ except httpx.HTTPStatusError as e:
1383
+ error_detail = e.response.text if e.response else "Unknown error"
1384
+ logger.error(f"API error: {e.response.status_code} - {error_detail}")
1385
+ if e.response.status_code == 401:
1386
+ raise ToolError("Authentication failed. Please login using the 'login' tool or configure your JWT token in the MCP client headers.")
1387
+ elif e.response.status_code == 404:
1388
+ raise ToolError(f"Memory not found: {memory_id_str}\nVerify the memory_id is correct by searching for it first.")
1389
+ raise ToolError(f"Failed to get neighborhood: HTTP {e.response.status_code} - {error_detail}")
1390
+ except ToolError:
1391
+ raise
1392
+ except Exception as e:
1393
+ logger.error(f"Unexpected error in get_neighborhood: {e}", exc_info=True)
1394
+ raise ToolError(f"Error getting neighborhood: {str(e)}")
1395
+
1396
+
1397
+ # ============================================================================
1398
+ # HELPER FUNCTIONS
1399
+ # ============================================================================
1400
+
1401
+ def _format_memory(memory: Optional[Dict[str, Any]]) -> str:
1402
+ """Format a single memory for display."""
1403
+ if not memory:
1404
+ return "Memory data not available"
1405
+
1406
+ lines = [
1407
+ f"ID: {memory.get('id', 'unknown')}",
1408
+ f"Content: {memory.get('content', '')[:200]}",
1409
+ f"Tags: {', '.join(memory.get('tags', []))}",
1410
+ f"Context: {memory.get('context', 'N/A')}",
1411
+ f"Links: {len(memory.get('links', []))} connections"
1412
+ ]
1413
+
1414
+ # Add evolution history if available
1415
+ evolution_history = memory.get('evolution_history', [])
1416
+ if evolution_history:
1417
+ lines.append(f"Evolution History: {len(evolution_history)} version(s)")
1418
+ # Show current version first
1419
+ current_content = memory.get('content', '')
1420
+ lines.append(f" Current Version: {current_content}")
1421
+ lines.append("")
1422
+ # Show historical versions (oldest to newest)
1423
+ for i, entry in enumerate(evolution_history, 1):
1424
+ if entry.get('type') == 'content_update':
1425
+ old_content = entry.get('old_content', '')
1426
+ timestamp = entry.get('timestamp', 'unknown')
1427
+ lines.append(f" Version {i} ({timestamp}): {old_content}")
1428
+ elif entry.get('type') == 'evolution':
1429
+ old_context = entry.get('old_context', 'N/A')
1430
+ new_context = entry.get('new_context', 'N/A')
1431
+ timestamp = entry.get('timestamp', 'unknown')
1432
+ lines.append(f" Evolution {i} ({timestamp}): Context '{old_context}' β†’ '{new_context}'")
1433
+
1434
+ return "\n".join(lines)
1435
+
1436
+
1437
+ def _format_memories_list(memories: List[Dict[str, Any]]) -> str:
1438
+ """Format a list of memories for display."""
1439
+ if not memories:
1440
+ return "No memories found"
1441
+
1442
+ formatted = []
1443
+ for i, mem in enumerate(memories, 1):
1444
+ formatted.append(f"{i}. {_format_memory(mem)}")
1445
+ return "\n\n".join(formatted)
1446
+
1447
+
1448
+ def _format_search_results(results: List[Dict[str, Any]]) -> str:
1449
+ """Format search results for display."""
1450
+ if not results:
1451
+ return "No results found"
1452
+
1453
+ formatted = []
1454
+ for i, result in enumerate(results, 1):
1455
+ if result.get("type") == "memory_node":
1456
+ formatted.append(f"{i}. Memory Node (score: {result.get('semantic_score', 0):.3f})")
1457
+ formatted.append(f" {_format_memory(result)}")
1458
+ related = result.get("related_memories", [])
1459
+ if related:
1460
+ formatted.append(f" Related: {len(related)} memories")
1461
+ elif result.get("type") == "relationship_edge":
1462
+ formatted.append(f"{i}. Relationship Edge (score: {result.get('score', 0):.3f})")
1463
+ source = result.get('source', {})
1464
+ target = result.get('target', {})
1465
+
1466
+ # Show source node data
1467
+ formatted.append(f" Source Node:")
1468
+ formatted.append(f" ID: {source.get('id', 'unknown')}")
1469
+ formatted.append(f" Content: {source.get('content', 'N/A')}")
1470
+ if source.get('context') and source.get('context') != 'General':
1471
+ formatted.append(f" Context: {source.get('context', 'N/A')}")
1472
+ if source.get('tags'):
1473
+ formatted.append(f" Tags: {', '.join(source.get('tags', []))}")
1474
+ if source.get('keywords'):
1475
+ formatted.append(f" Keywords: {', '.join(source.get('keywords', []))}")
1476
+
1477
+ # Show target node data
1478
+ formatted.append(f" Target Node:")
1479
+ formatted.append(f" ID: {target.get('id', 'unknown')}")
1480
+ formatted.append(f" Content: {target.get('content', 'N/A')}")
1481
+ if target.get('context') and target.get('context') != 'General':
1482
+ formatted.append(f" Context: {target.get('context', 'N/A')}")
1483
+ if target.get('tags'):
1484
+ formatted.append(f" Tags: {', '.join(target.get('tags', []))}")
1485
+ if target.get('keywords'):
1486
+ formatted.append(f" Keywords: {', '.join(target.get('keywords', []))}")
1487
+ formatted.append("")
1488
+
1489
+ return "\n".join(formatted)
1490
+
1491
+
1492
+ # Add health check endpoint for load balancers
1493
+ @mcp.custom_route("/health", methods=["GET"])
1494
+ async def health_check(request: Request) -> JSONResponse:
1495
+ """Health check endpoint for load balancers."""
1496
+ return JSONResponse(
1497
+ content={"status": "healthy", "service": "mem-brain-mcp"},
1498
+ status_code=200
1499
+ )
1500
+
1501
+
1502
+ def _mask_api_url(url: str) -> str:
1503
+ """Mask the API URL, showing only the first 1/4 and hiding the rest."""
1504
+ if not url:
1505
+ return "Not set"
1506
+ # Show first 1/4 of the URL, mask the rest
1507
+ url_length = len(url)
1508
+ visible_length = max(1, url_length // 4)
1509
+ if visible_length >= url_length:
1510
+ return url
1511
+ visible_part = url[:visible_length]
1512
+ masked_part = "*" * (url_length - visible_length)
1513
+ return f"{visible_part}{masked_part}"
1514
+
1515
+
1516
+ def run_server():
1517
+ """Run the FastMCP server with HTTP transport."""
1518
+ logger.info(f"Starting Mem-Brain MCP Server v{__version__} on {settings.mcp_server_host}:{settings.mcp_server_port}")
1519
+ logger.info(f"API URL: {_mask_api_url(settings.api_url)}")
1520
+ logger.info(f"API Key: {'***' if settings.api_key else 'Not set'}")
1521
+
1522
+ # Configure CORS for browser-based and MCP clients
1523
+ from starlette.middleware import Middleware
1524
+ from starlette.middleware.cors import CORSMiddleware
1525
+
1526
+ middleware = [
1527
+ Middleware(
1528
+ CORSMiddleware,
1529
+ allow_origins=["*"], # Allow all origins for MCP clients
1530
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
1531
+ allow_headers=[
1532
+ "mcp-protocol-version",
1533
+ "mcp-session-id",
1534
+ "Authorization",
1535
+ "Content-Type",
1536
+ "Accept",
1537
+ ],
1538
+ expose_headers=["mcp-session-id"],
1539
+ )
1540
+ ]
1541
+
1542
+ # Use http_app with CORS middleware and run with uvicorn
1543
+ # Note: http_app() handles the /mcp path automatically
1544
+ app = mcp.http_app(middleware=middleware, path="/mcp")
1545
+
1546
+ # Import uvicorn (should be available via FastMCP dependencies)
1547
+ try:
1548
+ import uvicorn
1549
+ except ImportError:
1550
+ # Fallback: use mcp.run if uvicorn not available
1551
+ logger.warning("uvicorn not available, using mcp.run() without CORS")
1552
+ mcp.run(
1553
+ transport="http",
1554
+ host=settings.mcp_server_host,
1555
+ port=settings.mcp_server_port,
1556
+ path="/mcp"
1557
+ )
1558
+ return
1559
+
1560
+ uvicorn.run(
1561
+ app,
1562
+ host=settings.mcp_server_host,
1563
+ port=settings.mcp_server_port,
1564
+ )