spatial-memory-mcp 1.0.2__py3-none-any.whl → 1.5.3__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.

Potentially problematic release.


This version of spatial-memory-mcp might be problematic. Click here for more details.

spatial_memory/server.py CHANGED
@@ -13,16 +13,18 @@ import logging
13
13
  import signal
14
14
  import sys
15
15
  import uuid
16
- from dataclasses import asdict
17
16
  from collections.abc import Callable
17
+ from dataclasses import asdict
18
18
  from typing import TYPE_CHECKING, Any
19
19
 
20
20
  from mcp.server import Server
21
21
  from mcp.server.stdio import stdio_server
22
22
  from mcp.types import TextContent, Tool
23
23
 
24
+ from spatial_memory import __version__
24
25
  from spatial_memory.adapters.lancedb_repository import LanceDBMemoryRepository
25
26
  from spatial_memory.config import ConfigurationError, get_settings, validate_startup
27
+ from spatial_memory.core.cache import ResponseCache
26
28
  from spatial_memory.core.database import (
27
29
  Database,
28
30
  clear_connection_cache,
@@ -49,7 +51,12 @@ from spatial_memory.core.health import HealthChecker
49
51
  from spatial_memory.core.logging import configure_logging
50
52
  from spatial_memory.core.metrics import is_available as metrics_available
51
53
  from spatial_memory.core.metrics import record_request
52
- from spatial_memory.core.rate_limiter import RateLimiter
54
+ from spatial_memory.core.rate_limiter import AgentAwareRateLimiter, RateLimiter
55
+ from spatial_memory.core.tracing import (
56
+ RequestContext,
57
+ TimingContext,
58
+ request_context,
59
+ )
53
60
  from spatial_memory.services.export_import import ExportImportConfig, ExportImportService
54
61
  from spatial_memory.services.lifecycle import LifecycleConfig, LifecycleService
55
62
  from spatial_memory.services.memory import MemoryService
@@ -65,6 +72,33 @@ if TYPE_CHECKING:
65
72
 
66
73
  logger = logging.getLogger(__name__)
67
74
 
75
+ # Tools that can be cached (read-only operations)
76
+ CACHEABLE_TOOLS = frozenset({"recall", "nearby", "hybrid_recall", "regions"})
77
+
78
+ # Tools that invalidate cache by namespace
79
+ NAMESPACE_INVALIDATING_TOOLS = frozenset({"remember", "forget", "forget_batch"})
80
+
81
+ # Tools that invalidate entire cache
82
+ FULL_INVALIDATING_TOOLS = frozenset({"decay", "reinforce", "consolidate"})
83
+
84
+
85
+ def _generate_cache_key(tool_name: str, arguments: dict[str, Any]) -> str:
86
+ """Generate a cache key from tool name and arguments.
87
+
88
+ Args:
89
+ tool_name: Name of the tool.
90
+ arguments: Tool arguments (excluding _agent_id).
91
+
92
+ Returns:
93
+ A string cache key suitable for response caching.
94
+ """
95
+ # Remove _agent_id from cache key computation (same query from different agents = same result)
96
+ cache_args = {k: v for k, v in sorted(arguments.items()) if k != "_agent_id"}
97
+ # Create a stable string representation
98
+ args_str = json.dumps(cache_args, sort_keys=True, default=str)
99
+ return f"{tool_name}:{hash(args_str)}"
100
+
101
+
68
102
  # Error type to response name mapping for standardized error responses
69
103
  ERROR_MAPPINGS: dict[type[Exception], str] = {
70
104
  MemoryNotFoundError: "MemoryNotFound",
@@ -243,13 +277,35 @@ class SpatialMemoryServer:
243
277
  self._embeddings = embeddings
244
278
 
245
279
  # Rate limiting for resource protection
246
- self._rate_limiter = RateLimiter(
247
- rate=self._settings.embedding_rate_limit,
248
- capacity=int(self._settings.embedding_rate_limit * 2)
249
- )
280
+ # Use per-agent rate limiter if enabled, otherwise fall back to simple rate limiter
281
+ self._per_agent_rate_limiting = self._settings.rate_limit_per_agent_enabled
282
+ self._agent_rate_limiter: AgentAwareRateLimiter | None = None
283
+ self._rate_limiter: RateLimiter | None = None
284
+ if self._per_agent_rate_limiting:
285
+ self._agent_rate_limiter = AgentAwareRateLimiter(
286
+ global_rate=self._settings.embedding_rate_limit,
287
+ per_agent_rate=self._settings.rate_limit_per_agent_rate,
288
+ max_agents=self._settings.rate_limit_max_tracked_agents,
289
+ )
290
+ else:
291
+ self._rate_limiter = RateLimiter(
292
+ rate=self._settings.embedding_rate_limit,
293
+ capacity=int(self._settings.embedding_rate_limit * 2)
294
+ )
295
+
296
+ # Response cache for read-only operations
297
+ self._cache_enabled = self._settings.response_cache_enabled
298
+ self._cache: ResponseCache | None = None
299
+ self._regions_cache_ttl = 0.0
300
+ if self._cache_enabled:
301
+ self._cache = ResponseCache(
302
+ max_size=self._settings.response_cache_max_size,
303
+ default_ttl=self._settings.response_cache_default_ttl,
304
+ )
305
+ self._regions_cache_ttl = self._settings.response_cache_regions_ttl
250
306
 
251
307
  # Tool handler registry for dispatch pattern
252
- self._tool_handlers: dict[str, Callable[[dict], dict]] = {
308
+ self._tool_handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
253
309
  "remember": self._handle_remember,
254
310
  "remember_batch": self._handle_remember_batch,
255
311
  "recall": self._handle_recall,
@@ -280,8 +336,12 @@ class SpatialMemoryServer:
280
336
  else:
281
337
  logger.info("Prometheus metrics disabled (prometheus_client not installed)")
282
338
 
283
- # Create MCP server
284
- self._server = Server("spatial-memory")
339
+ # Create MCP server with behavioral instructions
340
+ self._server = Server(
341
+ name="spatial-memory",
342
+ version=__version__,
343
+ instructions=self._get_server_instructions(),
344
+ )
285
345
  self._setup_handlers()
286
346
 
287
347
  def _setup_handlers(self) -> None:
@@ -294,27 +354,113 @@ class SpatialMemoryServer:
294
354
 
295
355
  @self._server.call_tool()
296
356
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
297
- """Handle tool calls."""
357
+ """Handle tool calls with tracing, caching, and rate limiting."""
358
+ # Extract _agent_id for tracing and rate limiting (don't pass to handler)
359
+ agent_id = arguments.pop("_agent_id", None)
360
+
298
361
  # Apply rate limiting
299
- if not self._rate_limiter.wait(timeout=30.0):
300
- return [TextContent(
301
- type="text",
302
- text=json.dumps({
303
- "error": "RateLimitExceeded",
304
- "message": "Too many requests. Please wait and try again.",
305
- "isError": True,
306
- })
307
- )]
308
-
309
- try:
310
- result = self._handle_tool(name, arguments)
311
- return [TextContent(type="text", text=json.dumps(result, default=str))]
312
- except tuple(ERROR_MAPPINGS.keys()) as e:
313
- return _create_error_response(e)
314
- except Exception as e:
315
- error_id = str(uuid.uuid4())[:8]
316
- logger.error(f"Unexpected error [{error_id}] in {name}: {e}", exc_info=True)
317
- return _create_error_response(e, error_id)
362
+ if self._per_agent_rate_limiting and self._agent_rate_limiter is not None:
363
+ if not self._agent_rate_limiter.wait(agent_id=agent_id, timeout=30.0):
364
+ return [TextContent(
365
+ type="text",
366
+ text=json.dumps({
367
+ "error": "RateLimitExceeded",
368
+ "message": "Too many requests. Please wait and try again.",
369
+ "isError": True,
370
+ })
371
+ )]
372
+ elif self._rate_limiter is not None:
373
+ if not self._rate_limiter.wait(timeout=30.0):
374
+ return [TextContent(
375
+ type="text",
376
+ text=json.dumps({
377
+ "error": "RateLimitExceeded",
378
+ "message": "Too many requests. Please wait and try again.",
379
+ "isError": True,
380
+ })
381
+ )]
382
+
383
+ # Use request context for tracing
384
+ namespace = arguments.get("namespace")
385
+ with request_context(tool_name=name, agent_id=agent_id, namespace=namespace) as ctx:
386
+ timing = TimingContext()
387
+ cache_hit = False
388
+
389
+ try:
390
+ # Check cache for cacheable tools
391
+ if self._cache_enabled and self._cache is not None and name in CACHEABLE_TOOLS:
392
+ cache_key = _generate_cache_key(name, arguments)
393
+ with timing.measure("cache_lookup"):
394
+ cached_result = self._cache.get(cache_key)
395
+ if cached_result is not None:
396
+ cache_hit = True
397
+ result = cached_result
398
+ else:
399
+ with timing.measure("handler"):
400
+ result = self._handle_tool(name, arguments)
401
+ # Cache the result with appropriate TTL
402
+ ttl = self._regions_cache_ttl if name == "regions" else None
403
+ self._cache.set(cache_key, result, ttl=ttl)
404
+ else:
405
+ with timing.measure("handler"):
406
+ result = self._handle_tool(name, arguments)
407
+
408
+ # Invalidate cache on mutations
409
+ if self._cache_enabled and self._cache is not None:
410
+ self._invalidate_cache_for_tool(name, arguments)
411
+
412
+ # Add _meta to response if enabled
413
+ if self._settings.include_request_meta:
414
+ result["_meta"] = self._build_meta(ctx, timing, cache_hit)
415
+
416
+ return [TextContent(type="text", text=json.dumps(result, default=str))]
417
+ except tuple(ERROR_MAPPINGS.keys()) as e:
418
+ return _create_error_response(e)
419
+ except Exception as e:
420
+ error_id = str(uuid.uuid4())[:8]
421
+ logger.error(f"Unexpected error [{error_id}] in {name}: {e}", exc_info=True)
422
+ return _create_error_response(e, error_id)
423
+
424
+ def _build_meta(
425
+ self,
426
+ ctx: RequestContext,
427
+ timing: TimingContext,
428
+ cache_hit: bool,
429
+ ) -> dict[str, Any]:
430
+ """Build the _meta object for response.
431
+
432
+ Args:
433
+ ctx: The request context.
434
+ timing: The timing context.
435
+ cache_hit: Whether this was a cache hit.
436
+
437
+ Returns:
438
+ Dictionary with request metadata.
439
+ """
440
+ meta: dict[str, Any] = {
441
+ "request_id": ctx.request_id,
442
+ "agent_id": ctx.agent_id,
443
+ "cache_hit": cache_hit,
444
+ }
445
+ if self._settings.include_timing_breakdown:
446
+ meta["timing_ms"] = timing.summary()
447
+ return meta
448
+
449
+ def _invalidate_cache_for_tool(self, name: str, arguments: dict[str, Any]) -> None:
450
+ """Invalidate cache entries based on the tool that was called.
451
+
452
+ Args:
453
+ name: Tool name.
454
+ arguments: Tool arguments.
455
+ """
456
+ if self._cache is None:
457
+ return
458
+
459
+ if name in FULL_INVALIDATING_TOOLS:
460
+ self._cache.invalidate_all()
461
+ elif name in NAMESPACE_INVALIDATING_TOOLS:
462
+ namespace = arguments.get("namespace", "default")
463
+ self._cache.invalidate_namespace(namespace)
318
464
 
319
465
  # =========================================================================
320
466
  # Tool Handler Methods
@@ -414,6 +560,7 @@ class SpatialMemoryServer:
414
560
  report = health_checker.get_health_report()
415
561
 
416
562
  result: dict[str, Any] = {
563
+ "version": __version__,
417
564
  "status": report.status.value,
418
565
  "timestamp": report.timestamp.isoformat(),
419
566
  "ready": health_checker.is_ready(),
@@ -904,6 +1051,60 @@ class SpatialMemoryServer:
904
1051
  raise ValidationError(f"Unknown tool: {name}")
905
1052
  return handler(arguments)
906
1053
 
1054
+ @staticmethod
1055
+ def _get_server_instructions() -> str:
1056
+ """Return behavioral instructions for Claude when using spatial-memory.
1057
+
1058
+ These instructions are automatically injected into Claude's system prompt
1059
+ when the MCP server connects, enabling proactive memory management without
1060
+ requiring user configuration.
1061
+ """
1062
+ return '''## Spatial Memory System
1063
+
1064
+ You have access to a persistent semantic memory system. Use it proactively to build cumulative knowledge across sessions.
1065
+
1066
+ ### Session Start
1067
+ At conversation start, call `recall` with the user's apparent task/context to load relevant memories. Present insights naturally:
1068
+ - Good: "Based on previous work, you decided to use PostgreSQL because..."
1069
+ - Bad: "The database returned: [{id: '...', content: '...'}]"
1070
+
1071
+ ### Recognizing Memory-Worthy Moments
1072
+ After these events, ask briefly "Save this? y/n" (minimal friction):
1073
+ - **Decisions**: "Let's use X...", "We decided...", "The approach is..."
1074
+ - **Solutions**: "The fix was...", "It failed because...", "The error was..."
1075
+ - **Patterns**: "This pattern works...", "The trick is...", "Always do X when..."
1076
+ - **Discoveries**: "I found that...", "Important:...", "TIL..."
1077
+
1078
+ Do NOT ask for trivial information. Only prompt for insights that would help future sessions.
1079
+
1080
+ ### Saving Memories
1081
+ When user confirms, save with:
1082
+ - **Detailed content**: Include full context, reasoning, and specifics. Future agents need complete information.
1083
+ - **Contextual namespace**: Use project name, or categories like "decisions", "errors", "patterns"
1084
+ - **Descriptive tags**: Technologies, concepts, error types involved
1085
+ - **High importance (0.8-1.0)**: For decisions and critical fixes
1086
+ - **Medium importance (0.5-0.7)**: For patterns and learnings
1087
+
1088
+ ### Synthesizing Answers
1089
+ When using `recall` or `hybrid_recall`, present results as natural knowledge:
1090
+ - Integrate memories into your response conversationally
1091
+ - Reference prior decisions: "You previously decided X because Y"
1092
+ - Don't expose raw JSON or tool mechanics to the user
1093
+
1094
+ ### Auto-Extract for Long Sessions
1095
+ For significant problem-solving conversations (debugging sessions, architecture discussions), offer:
1096
+ "This session had good learnings. Extract key memories? y/n"
1097
+ Then use `extract` to automatically capture important information.
1098
+
1099
+ ### Tool Selection Guide
1100
+ - `remember`: Store a single memory with full context
1101
+ - `recall`: Semantic search for relevant memories
1102
+ - `hybrid_recall`: Combined keyword + semantic search (better for specific terms)
1103
+ - `extract`: Auto-extract memories from conversation text
1104
+ - `nearby`: Find memories similar to a known memory
1105
+ - `regions`: Discover topic clusters in memory space
1106
+ - `journey`: Navigate conceptual path between two memories'''
1107
+
907
1108
  async def run(self) -> None:
908
1109
  """Run the MCP server using stdio transport."""
909
1110
  async with stdio_server() as (read_stream, write_stream):
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import logging
15
15
  from dataclasses import dataclass, field
16
- from typing import TYPE_CHECKING, Any
16
+ from typing import TYPE_CHECKING, Any, Protocol
17
17
 
18
18
  from spatial_memory.core.errors import MemoryNotFoundError, ValidationError
19
19
  from spatial_memory.core.models import Memory, MemorySource
@@ -22,6 +22,7 @@ from spatial_memory.core.validation import validate_content, validate_importance
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
24
  if TYPE_CHECKING:
25
+ from spatial_memory.core.database import IdempotencyRecord
25
26
  from spatial_memory.core.models import MemoryResult
26
27
  from spatial_memory.ports.repositories import (
27
28
  EmbeddingServiceProtocol,
@@ -29,6 +30,39 @@ if TYPE_CHECKING:
29
30
  )
30
31
 
31
32
 
33
+ class IdempotencyProviderProtocol(Protocol):
34
+ """Protocol for idempotency key storage and lookup.
35
+
36
+ Implementations should handle key-to-memory-id mappings with TTL support.
37
+ """
38
+
39
+ def get_by_idempotency_key(self, key: str) -> IdempotencyRecord | None:
40
+ """Look up an idempotency record by key.
41
+
42
+ Args:
43
+ key: The idempotency key to look up.
44
+
45
+ Returns:
46
+ IdempotencyRecord if found and not expired, None otherwise.
47
+ """
48
+ ...
49
+
50
+ def store_idempotency_key(
51
+ self,
52
+ key: str,
53
+ memory_id: str,
54
+ ttl_hours: float = 24.0,
55
+ ) -> None:
56
+ """Store an idempotency key mapping.
57
+
58
+ Args:
59
+ key: The idempotency key.
60
+ memory_id: The memory ID that was created.
61
+ ttl_hours: Time-to-live in hours (default: 24 hours).
62
+ """
63
+ ...
64
+
65
+
32
66
  @dataclass
33
67
  class RememberResult:
34
68
  """Result of storing a memory."""
@@ -36,6 +70,7 @@ class RememberResult:
36
70
  id: str
37
71
  content: str
38
72
  namespace: str
73
+ deduplicated: bool = False
39
74
 
40
75
 
41
76
  @dataclass
@@ -80,15 +115,18 @@ class MemoryService:
80
115
  self,
81
116
  repository: MemoryRepositoryProtocol,
82
117
  embeddings: EmbeddingServiceProtocol,
118
+ idempotency_provider: IdempotencyProviderProtocol | None = None,
83
119
  ) -> None:
84
120
  """Initialize the memory service.
85
121
 
86
122
  Args:
87
123
  repository: Repository for memory storage.
88
124
  embeddings: Service for generating embeddings.
125
+ idempotency_provider: Optional provider for idempotency key support.
89
126
  """
90
127
  self._repo = repository
91
128
  self._embeddings = embeddings
129
+ self._idempotency = idempotency_provider
92
130
 
93
131
  # Use centralized validation functions
94
132
  _validate_content = staticmethod(validate_content)
@@ -101,6 +139,7 @@ class MemoryService:
101
139
  tags: list[str] | None = None,
102
140
  importance: float = 0.5,
103
141
  metadata: dict[str, Any] | None = None,
142
+ idempotency_key: str | None = None,
104
143
  ) -> RememberResult:
105
144
  """Store a new memory.
106
145
 
@@ -110,13 +149,40 @@ class MemoryService:
110
149
  tags: Optional list of tags.
111
150
  importance: Importance score (0-1).
112
151
  metadata: Optional metadata dict.
152
+ idempotency_key: Optional key for idempotent requests. If provided
153
+ and a memory was already created with this key, returns the
154
+ existing memory ID with deduplicated=True.
113
155
 
114
156
  Returns:
115
- RememberResult with the new memory's ID.
157
+ RememberResult with the new memory's ID. If idempotency_key was
158
+ provided and matched an existing request, deduplicated=True.
116
159
 
117
160
  Raises:
118
161
  ValidationError: If input validation fails.
119
162
  """
163
+ # Check idempotency key first (before any expensive operations)
164
+ if idempotency_key and self._idempotency:
165
+ existing = self._idempotency.get_by_idempotency_key(idempotency_key)
166
+ if existing:
167
+ logger.debug(
168
+ f"Idempotency key '{idempotency_key}' matched existing "
169
+ f"memory '{existing.memory_id}'"
170
+ )
171
+ # Return cached result - fetch the memory to get content
172
+ cached_memory = self._repo.get(existing.memory_id)
173
+ if cached_memory:
174
+ return RememberResult(
175
+ id=existing.memory_id,
176
+ content=cached_memory.content,
177
+ namespace=cached_memory.namespace,
178
+ deduplicated=True,
179
+ )
180
+ # Memory was deleted but key exists - proceed with new insert
181
+ logger.warning(
182
+ f"Idempotency key '{idempotency_key}' references deleted "
183
+ f"memory '{existing.memory_id}', creating new memory"
184
+ )
185
+
120
186
  # Validate inputs
121
187
  self._validate_content(content)
122
188
  self._validate_importance(importance)
@@ -138,10 +204,21 @@ class MemoryService:
138
204
  # Store in repository
139
205
  memory_id = self._repo.add(memory, vector)
140
206
 
207
+ # Store idempotency key mapping if provided
208
+ if idempotency_key and self._idempotency:
209
+ try:
210
+ self._idempotency.store_idempotency_key(idempotency_key, memory_id)
211
+ except Exception as e:
212
+ # Log but don't fail the memory creation
213
+ logger.warning(
214
+ f"Failed to store idempotency key '{idempotency_key}': {e}"
215
+ )
216
+
141
217
  return RememberResult(
142
218
  id=memory_id,
143
219
  content=content,
144
220
  namespace=namespace,
221
+ deduplicated=False,
145
222
  )
146
223
 
147
224
  def remember_batch(