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.
- mem_brain_mcp/__init__.py +4 -0
- mem_brain_mcp/__main__.py +29 -0
- mem_brain_mcp/client.py +260 -0
- mem_brain_mcp/config.py +45 -0
- mem_brain_mcp/server.py +1564 -0
- mem_brain_mcp-1.0.6.dist-info/METADATA +208 -0
- mem_brain_mcp-1.0.6.dist-info/RECORD +9 -0
- mem_brain_mcp-1.0.6.dist-info/WHEEL +4 -0
- mem_brain_mcp-1.0.6.dist-info/entry_points.txt +2 -0
mem_brain_mcp/server.py
ADDED
|
@@ -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
|
+
)
|