spatial-memory-mcp 1.0.3__py3-none-any.whl → 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (39) hide show
  1. spatial_memory/__init__.py +97 -97
  2. spatial_memory/__main__.py +241 -2
  3. spatial_memory/adapters/lancedb_repository.py +74 -5
  4. spatial_memory/config.py +115 -2
  5. spatial_memory/core/__init__.py +35 -0
  6. spatial_memory/core/cache.py +317 -0
  7. spatial_memory/core/circuit_breaker.py +297 -0
  8. spatial_memory/core/connection_pool.py +41 -3
  9. spatial_memory/core/consolidation_strategies.py +402 -0
  10. spatial_memory/core/database.py +791 -769
  11. spatial_memory/core/db_idempotency.py +242 -0
  12. spatial_memory/core/db_indexes.py +575 -0
  13. spatial_memory/core/db_migrations.py +584 -0
  14. spatial_memory/core/db_search.py +509 -0
  15. spatial_memory/core/db_versioning.py +177 -0
  16. spatial_memory/core/embeddings.py +156 -19
  17. spatial_memory/core/errors.py +75 -3
  18. spatial_memory/core/filesystem.py +178 -0
  19. spatial_memory/core/logging.py +194 -103
  20. spatial_memory/core/models.py +4 -0
  21. spatial_memory/core/rate_limiter.py +326 -105
  22. spatial_memory/core/response_types.py +497 -0
  23. spatial_memory/core/tracing.py +300 -0
  24. spatial_memory/core/validation.py +403 -319
  25. spatial_memory/factory.py +407 -0
  26. spatial_memory/migrations/__init__.py +40 -0
  27. spatial_memory/ports/repositories.py +52 -2
  28. spatial_memory/server.py +329 -188
  29. spatial_memory/services/export_import.py +61 -43
  30. spatial_memory/services/lifecycle.py +397 -122
  31. spatial_memory/services/memory.py +81 -4
  32. spatial_memory/services/spatial.py +129 -46
  33. spatial_memory/tools/definitions.py +695 -671
  34. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/METADATA +83 -3
  35. spatial_memory_mcp-1.6.0.dist-info/RECORD +54 -0
  36. spatial_memory_mcp-1.0.3.dist-info/RECORD +0 -41
  37. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/WHEEL +0 -0
  38. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/entry_points.txt +0 -0
  39. {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/licenses/LICENSE +0 -0
spatial_memory/server.py CHANGED
@@ -13,22 +13,23 @@ 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 concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import asdict
19
+ from functools import partial
18
20
  from typing import TYPE_CHECKING, Any
19
21
 
20
22
  from mcp.server import Server
21
23
  from mcp.server.stdio import stdio_server
22
24
  from mcp.types import TextContent, Tool
23
25
 
24
- from spatial_memory.adapters.lancedb_repository import LanceDBMemoryRepository
26
+ from spatial_memory import __version__
25
27
  from spatial_memory.config import ConfigurationError, get_settings, validate_startup
28
+ from spatial_memory.factory import ServiceFactory
26
29
  from spatial_memory.core.database import (
27
- Database,
28
30
  clear_connection_cache,
29
31
  set_connection_pool_max_size,
30
32
  )
31
- from spatial_memory.core.embeddings import EmbeddingService
32
33
  from spatial_memory.core.errors import (
33
34
  ConsolidationError,
34
35
  DecayError,
@@ -45,17 +46,40 @@ from spatial_memory.core.errors import (
45
46
  SpatialMemoryError,
46
47
  ValidationError,
47
48
  )
49
+ from spatial_memory.core.response_types import (
50
+ ConsolidateResponse,
51
+ DecayResponse,
52
+ DeleteNamespaceResponse,
53
+ ExportResponse,
54
+ ExtractResponse,
55
+ ForgetBatchResponse,
56
+ ForgetResponse,
57
+ HandlerResponse,
58
+ HealthResponse,
59
+ HybridRecallResponse,
60
+ ImportResponse,
61
+ JourneyResponse,
62
+ NamespacesResponse,
63
+ NearbyResponse,
64
+ RecallResponse,
65
+ RegionsResponse,
66
+ ReinforceResponse,
67
+ RememberBatchResponse,
68
+ RememberResponse,
69
+ RenameNamespaceResponse,
70
+ StatsResponse,
71
+ VisualizeResponse,
72
+ WanderResponse,
73
+ )
48
74
  from spatial_memory.core.health import HealthChecker
49
75
  from spatial_memory.core.logging import configure_logging
50
76
  from spatial_memory.core.metrics import is_available as metrics_available
51
77
  from spatial_memory.core.metrics import record_request
52
- from spatial_memory.core.rate_limiter import RateLimiter
53
- from spatial_memory.services.export_import import ExportImportConfig, ExportImportService
54
- from spatial_memory.services.lifecycle import LifecycleConfig, LifecycleService
55
- from spatial_memory.services.memory import MemoryService
56
- from spatial_memory.services.spatial import SpatialConfig, SpatialService
57
- from spatial_memory.services.utility import UtilityConfig, UtilityService
58
- from spatial_memory import __version__
78
+ from spatial_memory.core.tracing import (
79
+ RequestContext,
80
+ TimingContext,
81
+ request_context,
82
+ )
59
83
  from spatial_memory.tools import TOOLS
60
84
 
61
85
  if TYPE_CHECKING:
@@ -66,6 +90,33 @@ if TYPE_CHECKING:
66
90
 
67
91
  logger = logging.getLogger(__name__)
68
92
 
93
+ # Tools that can be cached (read-only operations)
94
+ CACHEABLE_TOOLS = frozenset({"recall", "nearby", "hybrid_recall", "regions"})
95
+
96
+ # Tools that invalidate cache by namespace
97
+ NAMESPACE_INVALIDATING_TOOLS = frozenset({"remember", "forget", "forget_batch"})
98
+
99
+ # Tools that invalidate entire cache
100
+ FULL_INVALIDATING_TOOLS = frozenset({"decay", "reinforce", "consolidate"})
101
+
102
+
103
+ def _generate_cache_key(tool_name: str, arguments: dict[str, Any]) -> str:
104
+ """Generate a cache key from tool name and arguments.
105
+
106
+ Args:
107
+ tool_name: Name of the tool.
108
+ arguments: Tool arguments (excluding _agent_id).
109
+
110
+ Returns:
111
+ A string cache key suitable for response caching.
112
+ """
113
+ # Remove _agent_id from cache key computation (same query from different agents = same result)
114
+ cache_args = {k: v for k, v in sorted(arguments.items()) if k != "_agent_id"}
115
+ # Create a stable string representation
116
+ args_str = json.dumps(cache_args, sort_keys=True, default=str)
117
+ return f"{tool_name}:{hash(args_str)}"
118
+
119
+
69
120
  # Error type to response name mapping for standardized error responses
70
121
  ERROR_MAPPINGS: dict[type[Exception], str] = {
71
122
  MemoryNotFoundError: "MemoryNotFound",
@@ -116,141 +167,45 @@ class SpatialMemoryServer:
116
167
  embeddings: Optional embedding service (uses local model if not provided).
117
168
  """
118
169
  self._settings = get_settings()
119
- self._db: Database | None = None
120
170
 
121
171
  # Configure connection pool size from settings
122
172
  set_connection_pool_max_size(self._settings.connection_pool_max_size)
123
173
 
124
- # Set up dependencies
125
- if repository is None or embeddings is None:
126
- # Create embedding service FIRST to auto-detect dimensions
127
- if embeddings is None:
128
- embeddings = EmbeddingService(
129
- model_name=self._settings.embedding_model,
130
- openai_api_key=self._settings.openai_api_key,
131
- backend=self._settings.embedding_backend, # type: ignore[arg-type]
132
- )
133
-
134
- # Auto-detect embedding dimensions from the model
135
- embedding_dim = embeddings.dimensions
136
- logger.info(f"Auto-detected embedding dimensions: {embedding_dim}")
137
- logger.info(f"Embedding backend: {embeddings.backend}")
138
-
139
- # Create database with all config values wired
140
- self._db = Database(
141
- storage_path=self._settings.memory_path,
142
- embedding_dim=embedding_dim,
143
- auto_create_indexes=self._settings.auto_create_indexes,
144
- vector_index_threshold=self._settings.vector_index_threshold,
145
- enable_fts=self._settings.enable_fts_index,
146
- index_nprobes=self._settings.index_nprobes,
147
- index_refine_factor=self._settings.index_refine_factor,
148
- max_retry_attempts=self._settings.max_retry_attempts,
149
- retry_backoff_seconds=self._settings.retry_backoff_seconds,
150
- read_consistency_interval_ms=self._settings.read_consistency_interval_ms,
151
- index_wait_timeout_seconds=self._settings.index_wait_timeout_seconds,
152
- fts_stem=self._settings.fts_stem,
153
- fts_remove_stop_words=self._settings.fts_remove_stop_words,
154
- fts_language=self._settings.fts_language,
155
- index_type=self._settings.index_type,
156
- hnsw_m=self._settings.hnsw_m,
157
- hnsw_ef_construction=self._settings.hnsw_ef_construction,
158
- enable_memory_expiration=self._settings.enable_memory_expiration,
159
- default_memory_ttl_days=self._settings.default_memory_ttl_days,
160
- )
161
- self._db.connect()
162
-
163
- if repository is None:
164
- repository = LanceDBMemoryRepository(self._db)
165
-
166
- self._memory_service = MemoryService(
167
- repository=repository,
168
- embeddings=embeddings,
169
- )
170
-
171
- # Create spatial service for exploration operations
172
- self._spatial_service = SpatialService(
174
+ # Use ServiceFactory for dependency injection
175
+ factory = ServiceFactory(
176
+ settings=self._settings,
173
177
  repository=repository,
174
178
  embeddings=embeddings,
175
- config=SpatialConfig(
176
- journey_max_steps=self._settings.max_journey_steps,
177
- wander_max_steps=self._settings.max_wander_steps,
178
- regions_max_memories=self._settings.regions_max_memories,
179
- visualize_max_memories=self._settings.max_visualize_memories,
180
- visualize_n_neighbors=self._settings.umap_n_neighbors,
181
- visualize_min_dist=self._settings.umap_min_dist,
182
- visualize_similarity_threshold=self._settings.visualize_similarity_threshold,
183
- ),
184
179
  )
185
-
186
- # Create lifecycle service for memory lifecycle management
187
- self._lifecycle_service = LifecycleService(
188
- repository=repository,
189
- embeddings=embeddings,
190
- config=LifecycleConfig(
191
- decay_default_half_life_days=self._settings.decay_default_half_life_days,
192
- decay_default_function=self._settings.decay_default_function,
193
- decay_min_importance_floor=self._settings.decay_min_importance_floor,
194
- decay_batch_size=self._settings.decay_batch_size,
195
- reinforce_default_boost=self._settings.reinforce_default_boost,
196
- reinforce_max_importance=self._settings.reinforce_max_importance,
197
- extract_max_text_length=self._settings.extract_max_text_length,
198
- extract_max_candidates=self._settings.extract_max_candidates,
199
- extract_default_importance=self._settings.extract_default_importance,
200
- extract_default_namespace=self._settings.extract_default_namespace,
201
- consolidate_min_threshold=self._settings.consolidate_min_threshold,
202
- consolidate_content_weight=self._settings.consolidate_content_weight,
203
- consolidate_max_batch=self._settings.consolidate_max_batch,
204
- ),
205
- )
206
-
207
- # Create utility service for stats, namespaces, and hybrid search
208
- self._utility_service = UtilityService(
209
- repository=repository,
210
- embeddings=embeddings,
211
- config=UtilityConfig(
212
- hybrid_default_alpha=self._settings.hybrid_default_alpha,
213
- hybrid_min_alpha=self._settings.hybrid_min_alpha,
214
- hybrid_max_alpha=self._settings.hybrid_max_alpha,
215
- stats_include_index_details=True,
216
- namespace_batch_size=self._settings.namespace_batch_size,
217
- delete_namespace_require_confirmation=self._settings.destructive_require_namespace_confirmation,
218
- ),
219
- )
220
-
221
- # Create export/import service for data portability
222
- self._export_import_service = ExportImportService(
223
- repository=repository,
224
- embeddings=embeddings,
225
- config=ExportImportConfig(
226
- default_export_format=self._settings.export_default_format,
227
- export_batch_size=self._settings.export_batch_size,
228
- import_batch_size=self._settings.import_batch_size,
229
- import_deduplicate=self._settings.import_deduplicate_default,
230
- import_dedup_threshold=self._settings.import_dedup_threshold,
231
- validate_on_import=self._settings.import_validate_vectors,
232
- parquet_compression="zstd",
233
- max_import_records=self._settings.import_max_records,
234
- csv_include_vectors=self._settings.csv_include_vectors,
235
- max_export_records=self._settings.max_export_records,
236
- ),
237
- allowed_export_paths=self._settings.export_allowed_paths,
238
- allowed_import_paths=self._settings.import_allowed_paths,
239
- allow_symlinks=self._settings.export_allow_symlinks,
240
- max_import_size_bytes=int(self._settings.import_max_file_size_mb * 1024 * 1024),
241
- )
242
-
243
- # Store embeddings and database for health checks
244
- self._embeddings = embeddings
245
-
246
- # Rate limiting for resource protection
247
- self._rate_limiter = RateLimiter(
248
- rate=self._settings.embedding_rate_limit,
249
- capacity=int(self._settings.embedding_rate_limit * 2)
180
+ services = factory.create_all()
181
+
182
+ # Store service references
183
+ self._db = services.database
184
+ self._embeddings = services.embeddings
185
+ self._memory_service = services.memory
186
+ self._spatial_service = services.spatial
187
+ self._lifecycle_service = services.lifecycle
188
+ self._utility_service = services.utility
189
+ self._export_import_service = services.export_import
190
+
191
+ # Rate limiting
192
+ self._per_agent_rate_limiting = services.per_agent_rate_limiting
193
+ self._rate_limiter = services.rate_limiter
194
+ self._agent_rate_limiter = services.agent_rate_limiter
195
+
196
+ # Response cache
197
+ self._cache_enabled = services.cache_enabled
198
+ self._cache = services.cache
199
+ self._regions_cache_ttl = services.regions_cache_ttl
200
+
201
+ # ThreadPoolExecutor for non-blocking embedding operations
202
+ self._executor = ThreadPoolExecutor(
203
+ max_workers=2,
204
+ thread_name_prefix="embed-",
250
205
  )
251
206
 
252
207
  # Tool handler registry for dispatch pattern
253
- self._tool_handlers: dict[str, Callable[[dict], dict]] = {
208
+ self._tool_handlers: dict[str, Callable[[dict[str, Any]], HandlerResponse]] = {
254
209
  "remember": self._handle_remember,
255
210
  "remember_batch": self._handle_remember_batch,
256
211
  "recall": self._handle_recall,
@@ -281,10 +236,50 @@ class SpatialMemoryServer:
281
236
  else:
282
237
  logger.info("Prometheus metrics disabled (prometheus_client not installed)")
283
238
 
284
- # Create MCP server
285
- self._server = Server("spatial-memory")
239
+ # Create MCP server with behavioral instructions
240
+ self._server = Server(
241
+ name="spatial-memory",
242
+ version=__version__,
243
+ instructions=self._get_server_instructions(),
244
+ )
286
245
  self._setup_handlers()
287
246
 
247
+ async def _run_in_executor(self, func: Callable[..., Any], *args: Any) -> Any:
248
+ """Run a synchronous function in the thread pool executor.
249
+
250
+ This allows CPU-bound or blocking operations (like embedding generation)
251
+ to run without blocking the asyncio event loop.
252
+
253
+ Args:
254
+ func: The synchronous function to run.
255
+ *args: Arguments to pass to the function.
256
+
257
+ Returns:
258
+ The result of the function call.
259
+ """
260
+ loop = asyncio.get_running_loop()
261
+ return await loop.run_in_executor(self._executor, partial(func, *args))
262
+
263
+ async def _handle_tool_async(
264
+ self, name: str, arguments: dict[str, Any]
265
+ ) -> HandlerResponse:
266
+ """Handle tool call asynchronously by running handler in executor.
267
+
268
+ This wraps synchronous handlers to run in a thread pool, preventing
269
+ blocking operations from stalling the event loop.
270
+
271
+ Args:
272
+ name: Tool name.
273
+ arguments: Tool arguments.
274
+
275
+ Returns:
276
+ Tool result as typed dictionary.
277
+
278
+ Raises:
279
+ ValidationError: If tool name is unknown.
280
+ """
281
+ return await self._run_in_executor(self._handle_tool, name, arguments)
282
+
288
283
  def _setup_handlers(self) -> None:
289
284
  """Set up MCP tool handlers."""
290
285
 
@@ -295,33 +290,121 @@ class SpatialMemoryServer:
295
290
 
296
291
  @self._server.call_tool()
297
292
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
298
- """Handle tool calls."""
293
+ """Handle tool calls with tracing, caching, and rate limiting."""
294
+ # Extract _agent_id for tracing and rate limiting (don't pass to handler)
295
+ agent_id = arguments.pop("_agent_id", None)
296
+
299
297
  # Apply rate limiting
300
- if not self._rate_limiter.wait(timeout=30.0):
301
- return [TextContent(
302
- type="text",
303
- text=json.dumps({
304
- "error": "RateLimitExceeded",
305
- "message": "Too many requests. Please wait and try again.",
306
- "isError": True,
307
- })
308
- )]
309
-
310
- try:
311
- result = self._handle_tool(name, arguments)
312
- return [TextContent(type="text", text=json.dumps(result, default=str))]
313
- except tuple(ERROR_MAPPINGS.keys()) as e:
314
- return _create_error_response(e)
315
- except Exception as e:
316
- error_id = str(uuid.uuid4())[:8]
317
- logger.error(f"Unexpected error [{error_id}] in {name}: {e}", exc_info=True)
318
- return _create_error_response(e, error_id)
298
+ if self._per_agent_rate_limiting and self._agent_rate_limiter is not None:
299
+ if not self._agent_rate_limiter.wait(agent_id=agent_id, 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
+ elif self._rate_limiter is not None:
309
+ if not self._rate_limiter.wait(timeout=30.0):
310
+ return [TextContent(
311
+ type="text",
312
+ text=json.dumps({
313
+ "error": "RateLimitExceeded",
314
+ "message": "Too many requests. Please wait and try again.",
315
+ "isError": True,
316
+ })
317
+ )]
318
+
319
+ # Use request context for tracing
320
+ namespace = arguments.get("namespace")
321
+ with request_context(tool_name=name, agent_id=agent_id, namespace=namespace) as ctx:
322
+ timing = TimingContext()
323
+ cache_hit = False
324
+
325
+ try:
326
+ # Check cache for cacheable tools
327
+ if self._cache_enabled and self._cache is not None and name in CACHEABLE_TOOLS:
328
+ cache_key = _generate_cache_key(name, arguments)
329
+ with timing.measure("cache_lookup"):
330
+ cached_result = self._cache.get(cache_key)
331
+ if cached_result is not None:
332
+ cache_hit = True
333
+ result = cached_result
334
+ else:
335
+ with timing.measure("handler"):
336
+ # Run handler in executor to avoid blocking event loop
337
+ result = await self._handle_tool_async(name, arguments)
338
+ # Cache the result with appropriate TTL
339
+ ttl = self._regions_cache_ttl if name == "regions" else None
340
+ self._cache.set(cache_key, result, ttl=ttl)
341
+ else:
342
+ with timing.measure("handler"):
343
+ # Run handler in executor to avoid blocking event loop
344
+ result = await self._handle_tool_async(name, arguments)
345
+
346
+ # Invalidate cache on mutations
347
+ if self._cache_enabled and self._cache is not None:
348
+ self._invalidate_cache_for_tool(name, arguments)
349
+
350
+ # Add _meta to response if enabled
351
+ if self._settings.include_request_meta:
352
+ result["_meta"] = self._build_meta(ctx, timing, cache_hit)
353
+
354
+ return [TextContent(type="text", text=json.dumps(result, default=str))]
355
+ except tuple(ERROR_MAPPINGS.keys()) as e:
356
+ return _create_error_response(e)
357
+ except Exception as e:
358
+ error_id = str(uuid.uuid4())[:8]
359
+ logger.error(f"Unexpected error [{error_id}] in {name}: {e}", exc_info=True)
360
+ return _create_error_response(e, error_id)
361
+
362
+ def _build_meta(
363
+ self,
364
+ ctx: RequestContext,
365
+ timing: TimingContext,
366
+ cache_hit: bool,
367
+ ) -> dict[str, Any]:
368
+ """Build the _meta object for response.
369
+
370
+ Args:
371
+ ctx: The request context.
372
+ timing: The timing context.
373
+ cache_hit: Whether this was a cache hit.
374
+
375
+ Returns:
376
+ Dictionary with request metadata.
377
+ """
378
+ meta: dict[str, Any] = {
379
+ "request_id": ctx.request_id,
380
+ "agent_id": ctx.agent_id,
381
+ "cache_hit": cache_hit,
382
+ }
383
+ if self._settings.include_timing_breakdown:
384
+ meta["timing_ms"] = timing.summary()
385
+ return meta
386
+
387
+ def _invalidate_cache_for_tool(self, name: str, arguments: dict[str, Any]) -> None:
388
+ """Invalidate cache entries based on the tool that was called.
389
+
390
+ Args:
391
+ name: Tool name.
392
+ arguments: Tool arguments.
393
+ """
394
+ if self._cache is None:
395
+ return
396
+
397
+ if name in FULL_INVALIDATING_TOOLS:
398
+ self._cache.invalidate_all()
399
+ elif name in NAMESPACE_INVALIDATING_TOOLS:
400
+ namespace = arguments.get("namespace", "default")
401
+ self._cache.invalidate_namespace(namespace)
319
402
 
320
403
  # =========================================================================
321
404
  # Tool Handler Methods
322
405
  # =========================================================================
323
406
 
324
- def _handle_remember(self, arguments: dict[str, Any]) -> dict[str, Any]:
407
+ def _handle_remember(self, arguments: dict[str, Any]) -> RememberResponse:
325
408
  """Handle remember tool call."""
326
409
  remember_result = self._memory_service.remember(
327
410
  content=arguments["content"],
@@ -330,16 +413,16 @@ class SpatialMemoryServer:
330
413
  importance=arguments.get("importance", 0.5),
331
414
  metadata=arguments.get("metadata"),
332
415
  )
333
- return asdict(remember_result)
416
+ return asdict(remember_result) # type: ignore[return-value]
334
417
 
335
- def _handle_remember_batch(self, arguments: dict[str, Any]) -> dict[str, Any]:
418
+ def _handle_remember_batch(self, arguments: dict[str, Any]) -> RememberBatchResponse:
336
419
  """Handle remember_batch tool call."""
337
420
  batch_result = self._memory_service.remember_batch(
338
421
  memories=arguments["memories"],
339
422
  )
340
- return asdict(batch_result)
423
+ return asdict(batch_result) # type: ignore[return-value]
341
424
 
342
- def _handle_recall(self, arguments: dict[str, Any]) -> dict[str, Any]:
425
+ def _handle_recall(self, arguments: dict[str, Any]) -> RecallResponse:
343
426
  """Handle recall tool call."""
344
427
  recall_result = self._memory_service.recall(
345
428
  query=arguments["query"],
@@ -364,7 +447,7 @@ class SpatialMemoryServer:
364
447
  "total": recall_result.total,
365
448
  }
366
449
 
367
- def _handle_nearby(self, arguments: dict[str, Any]) -> dict[str, Any]:
450
+ def _handle_nearby(self, arguments: dict[str, Any]) -> NearbyResponse:
368
451
  """Handle nearby tool call."""
369
452
  nearby_result = self._memory_service.nearby(
370
453
  memory_id=arguments["memory_id"],
@@ -388,21 +471,21 @@ class SpatialMemoryServer:
388
471
  ],
389
472
  }
390
473
 
391
- def _handle_forget(self, arguments: dict[str, Any]) -> dict[str, Any]:
474
+ def _handle_forget(self, arguments: dict[str, Any]) -> ForgetResponse:
392
475
  """Handle forget tool call."""
393
476
  forget_result = self._memory_service.forget(
394
477
  memory_id=arguments["memory_id"],
395
478
  )
396
- return asdict(forget_result)
479
+ return asdict(forget_result) # type: ignore[return-value]
397
480
 
398
- def _handle_forget_batch(self, arguments: dict[str, Any]) -> dict[str, Any]:
481
+ def _handle_forget_batch(self, arguments: dict[str, Any]) -> ForgetBatchResponse:
399
482
  """Handle forget_batch tool call."""
400
483
  forget_batch_result = self._memory_service.forget_batch(
401
484
  memory_ids=arguments["memory_ids"],
402
485
  )
403
- return asdict(forget_batch_result)
486
+ return asdict(forget_batch_result) # type: ignore[return-value]
404
487
 
405
- def _handle_health(self, arguments: dict[str, Any]) -> dict[str, Any]:
488
+ def _handle_health(self, arguments: dict[str, Any]) -> HealthResponse:
406
489
  """Handle health tool call."""
407
490
  verbose = arguments.get("verbose", False)
408
491
 
@@ -414,7 +497,7 @@ class SpatialMemoryServer:
414
497
 
415
498
  report = health_checker.get_health_report()
416
499
 
417
- result: dict[str, Any] = {
500
+ result: HealthResponse = {
418
501
  "version": __version__,
419
502
  "status": report.status.value,
420
503
  "timestamp": report.timestamp.isoformat(),
@@ -435,7 +518,7 @@ class SpatialMemoryServer:
435
518
 
436
519
  return result
437
520
 
438
- def _handle_journey(self, arguments: dict[str, Any]) -> dict[str, Any]:
521
+ def _handle_journey(self, arguments: dict[str, Any]) -> JourneyResponse:
439
522
  """Handle journey tool call."""
440
523
  journey_result = self._spatial_service.journey(
441
524
  start_id=arguments["start_id"],
@@ -465,7 +548,7 @@ class SpatialMemoryServer:
465
548
  "path_coverage": journey_result.path_coverage,
466
549
  }
467
550
 
468
- def _handle_wander(self, arguments: dict[str, Any]) -> dict[str, Any]:
551
+ def _handle_wander(self, arguments: dict[str, Any]) -> WanderResponse:
469
552
  """Handle wander tool call."""
470
553
  start_id = arguments.get("start_id")
471
554
  if start_id is None:
@@ -504,7 +587,7 @@ class SpatialMemoryServer:
504
587
  "total_distance": wander_result.total_distance,
505
588
  }
506
589
 
507
- def _handle_regions(self, arguments: dict[str, Any]) -> dict[str, Any]:
590
+ def _handle_regions(self, arguments: dict[str, Any]) -> RegionsResponse:
508
591
  """Handle regions tool call."""
509
592
  regions_result = self._spatial_service.regions(
510
593
  namespace=arguments.get("namespace"),
@@ -538,7 +621,7 @@ class SpatialMemoryServer:
538
621
  "clustering_quality": regions_result.clustering_quality,
539
622
  }
540
623
 
541
- def _handle_visualize(self, arguments: dict[str, Any]) -> dict[str, Any]:
624
+ def _handle_visualize(self, arguments: dict[str, Any]) -> VisualizeResponse:
542
625
  """Handle visualize tool call."""
543
626
  visualize_result = self._spatial_service.visualize(
544
627
  memory_ids=arguments.get("memory_ids"),
@@ -578,7 +661,7 @@ class SpatialMemoryServer:
578
661
  "format": visualize_result.format,
579
662
  }
580
663
 
581
- def _handle_decay(self, arguments: dict[str, Any]) -> dict[str, Any]:
664
+ def _handle_decay(self, arguments: dict[str, Any]) -> DecayResponse:
582
665
  """Handle decay tool call."""
583
666
  decay_result = self._lifecycle_service.decay(
584
667
  namespace=arguments.get("namespace"),
@@ -607,7 +690,7 @@ class SpatialMemoryServer:
607
690
  "dry_run": decay_result.dry_run,
608
691
  }
609
692
 
610
- def _handle_reinforce(self, arguments: dict[str, Any]) -> dict[str, Any]:
693
+ def _handle_reinforce(self, arguments: dict[str, Any]) -> ReinforceResponse:
611
694
  """Handle reinforce tool call."""
612
695
  reinforce_result = self._lifecycle_service.reinforce(
613
696
  memory_ids=arguments["memory_ids"],
@@ -631,7 +714,7 @@ class SpatialMemoryServer:
631
714
  "not_found": reinforce_result.not_found,
632
715
  }
633
716
 
634
- def _handle_extract(self, arguments: dict[str, Any]) -> dict[str, Any]:
717
+ def _handle_extract(self, arguments: dict[str, Any]) -> ExtractResponse:
635
718
  """Handle extract tool call."""
636
719
  extract_result = self._lifecycle_service.extract(
637
720
  text=arguments["text"],
@@ -658,7 +741,7 @@ class SpatialMemoryServer:
658
741
  ],
659
742
  }
660
743
 
661
- def _handle_consolidate(self, arguments: dict[str, Any]) -> dict[str, Any]:
744
+ def _handle_consolidate(self, arguments: dict[str, Any]) -> ConsolidateResponse:
662
745
  """Handle consolidate tool call."""
663
746
  consolidate_result = self._lifecycle_service.consolidate(
664
747
  namespace=arguments["namespace"],
@@ -683,7 +766,7 @@ class SpatialMemoryServer:
683
766
  "dry_run": consolidate_result.dry_run,
684
767
  }
685
768
 
686
- def _handle_stats(self, arguments: dict[str, Any]) -> dict[str, Any]:
769
+ def _handle_stats(self, arguments: dict[str, Any]) -> StatsResponse:
687
770
  """Handle stats tool call."""
688
771
  stats_result = self._utility_service.stats(
689
772
  namespace=arguments.get("namespace"),
@@ -721,7 +804,7 @@ class SpatialMemoryServer:
721
804
  "avg_content_length": stats_result.avg_content_length,
722
805
  }
723
806
 
724
- def _handle_namespaces(self, arguments: dict[str, Any]) -> dict[str, Any]:
807
+ def _handle_namespaces(self, arguments: dict[str, Any]) -> NamespacesResponse:
725
808
  """Handle namespaces tool call."""
726
809
  namespaces_result = self._utility_service.namespaces(
727
810
  include_stats=arguments.get("include_stats", True),
@@ -744,7 +827,7 @@ class SpatialMemoryServer:
744
827
  "total_memories": namespaces_result.total_memories,
745
828
  }
746
829
 
747
- def _handle_delete_namespace(self, arguments: dict[str, Any]) -> dict[str, Any]:
830
+ def _handle_delete_namespace(self, arguments: dict[str, Any]) -> DeleteNamespaceResponse:
748
831
  """Handle delete_namespace tool call."""
749
832
  delete_result = self._utility_service.delete_namespace(
750
833
  namespace=arguments["namespace"],
@@ -759,7 +842,7 @@ class SpatialMemoryServer:
759
842
  "dry_run": delete_result.dry_run,
760
843
  }
761
844
 
762
- def _handle_rename_namespace(self, arguments: dict[str, Any]) -> dict[str, Any]:
845
+ def _handle_rename_namespace(self, arguments: dict[str, Any]) -> RenameNamespaceResponse:
763
846
  """Handle rename_namespace tool call."""
764
847
  rename_result = self._utility_service.rename_namespace(
765
848
  old_namespace=arguments["old_namespace"],
@@ -773,7 +856,7 @@ class SpatialMemoryServer:
773
856
  "message": rename_result.message,
774
857
  }
775
858
 
776
- def _handle_export_memories(self, arguments: dict[str, Any]) -> dict[str, Any]:
859
+ def _handle_export_memories(self, arguments: dict[str, Any]) -> ExportResponse:
777
860
  """Handle export_memories tool call."""
778
861
  export_result = self._export_import_service.export_memories(
779
862
  output_path=arguments["output_path"],
@@ -792,7 +875,7 @@ class SpatialMemoryServer:
792
875
  "compression": export_result.compression,
793
876
  }
794
877
 
795
- def _handle_import_memories(self, arguments: dict[str, Any]) -> dict[str, Any]:
878
+ def _handle_import_memories(self, arguments: dict[str, Any]) -> ImportResponse:
796
879
  """Handle import_memories tool call."""
797
880
  dry_run = arguments.get("dry_run", True)
798
881
  import_result = self._export_import_service.import_memories(
@@ -834,7 +917,7 @@ class SpatialMemoryServer:
834
917
  ] if import_result.imported_memories else [],
835
918
  }
836
919
 
837
- def _handle_hybrid_recall(self, arguments: dict[str, Any]) -> dict[str, Any]:
920
+ def _handle_hybrid_recall(self, arguments: dict[str, Any]) -> HybridRecallResponse:
838
921
  """Handle hybrid_recall tool call."""
839
922
  hybrid_result = self._utility_service.hybrid_recall(
840
923
  query=arguments["query"],
@@ -871,7 +954,7 @@ class SpatialMemoryServer:
871
954
  # Tool Routing
872
955
  # =========================================================================
873
956
 
874
- def _handle_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
957
+ def _handle_tool(self, name: str, arguments: dict[str, Any]) -> HandlerResponse:
875
958
  """Route tool call to appropriate handler.
876
959
 
877
960
  Args:
@@ -879,7 +962,7 @@ class SpatialMemoryServer:
879
962
  arguments: Tool arguments.
880
963
 
881
964
  Returns:
882
- Tool result as dictionary.
965
+ Tool result as typed dictionary.
883
966
 
884
967
  Raises:
885
968
  ValidationError: If tool name is unknown.
@@ -888,7 +971,7 @@ class SpatialMemoryServer:
888
971
  with record_request(name, "success"):
889
972
  return self._handle_tool_impl(name, arguments)
890
973
 
891
- def _handle_tool_impl(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
974
+ def _handle_tool_impl(self, name: str, arguments: dict[str, Any]) -> HandlerResponse:
892
975
  """Implementation of tool routing using dispatch pattern.
893
976
 
894
977
  Args:
@@ -906,6 +989,60 @@ class SpatialMemoryServer:
906
989
  raise ValidationError(f"Unknown tool: {name}")
907
990
  return handler(arguments)
908
991
 
992
+ @staticmethod
993
+ def _get_server_instructions() -> str:
994
+ """Return behavioral instructions for Claude when using spatial-memory.
995
+
996
+ These instructions are automatically injected into Claude's system prompt
997
+ when the MCP server connects, enabling proactive memory management without
998
+ requiring user configuration.
999
+ """
1000
+ return '''## Spatial Memory System
1001
+
1002
+ You have access to a persistent semantic memory system. Use it proactively to build cumulative knowledge across sessions.
1003
+
1004
+ ### Session Start
1005
+ At conversation start, call `recall` with the user's apparent task/context to load relevant memories. Present insights naturally:
1006
+ - Good: "Based on previous work, you decided to use PostgreSQL because..."
1007
+ - Bad: "The database returned: [{id: '...', content: '...'}]"
1008
+
1009
+ ### Recognizing Memory-Worthy Moments
1010
+ After these events, ask briefly "Save this? y/n" (minimal friction):
1011
+ - **Decisions**: "Let's use X...", "We decided...", "The approach is..."
1012
+ - **Solutions**: "The fix was...", "It failed because...", "The error was..."
1013
+ - **Patterns**: "This pattern works...", "The trick is...", "Always do X when..."
1014
+ - **Discoveries**: "I found that...", "Important:...", "TIL..."
1015
+
1016
+ Do NOT ask for trivial information. Only prompt for insights that would help future sessions.
1017
+
1018
+ ### Saving Memories
1019
+ When user confirms, save with:
1020
+ - **Detailed content**: Include full context, reasoning, and specifics. Future agents need complete information.
1021
+ - **Contextual namespace**: Use project name, or categories like "decisions", "errors", "patterns"
1022
+ - **Descriptive tags**: Technologies, concepts, error types involved
1023
+ - **High importance (0.8-1.0)**: For decisions and critical fixes
1024
+ - **Medium importance (0.5-0.7)**: For patterns and learnings
1025
+
1026
+ ### Synthesizing Answers
1027
+ When using `recall` or `hybrid_recall`, present results as natural knowledge:
1028
+ - Integrate memories into your response conversationally
1029
+ - Reference prior decisions: "You previously decided X because Y"
1030
+ - Don't expose raw JSON or tool mechanics to the user
1031
+
1032
+ ### Auto-Extract for Long Sessions
1033
+ For significant problem-solving conversations (debugging sessions, architecture discussions), offer:
1034
+ "This session had good learnings. Extract key memories? y/n"
1035
+ Then use `extract` to automatically capture important information.
1036
+
1037
+ ### Tool Selection Guide
1038
+ - `remember`: Store a single memory with full context
1039
+ - `recall`: Semantic search for relevant memories
1040
+ - `hybrid_recall`: Combined keyword + semantic search (better for specific terms)
1041
+ - `extract`: Auto-extract memories from conversation text
1042
+ - `nearby`: Find memories similar to a known memory
1043
+ - `regions`: Discover topic clusters in memory space
1044
+ - `journey`: Navigate conceptual path between two memories'''
1045
+
909
1046
  async def run(self) -> None:
910
1047
  """Run the MCP server using stdio transport."""
911
1048
  async with stdio_server() as (read_stream, write_stream):
@@ -917,6 +1054,10 @@ class SpatialMemoryServer:
917
1054
 
918
1055
  def close(self) -> None:
919
1056
  """Clean up resources."""
1057
+ # Shutdown the thread pool executor
1058
+ if hasattr(self, "_executor"):
1059
+ self._executor.shutdown(wait=False)
1060
+
920
1061
  if self._db is not None:
921
1062
  self._db.close()
922
1063