basic-memory 0.16.1__py3-none-any.whl → 0.17.4__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 basic-memory might be problematic. Click here for more details.

Files changed (143) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/env.py +112 -26
  3. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  4. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +15 -3
  5. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +44 -36
  6. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  7. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  8. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +13 -0
  9. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  10. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  11. basic_memory/api/app.py +45 -24
  12. basic_memory/api/container.py +133 -0
  13. basic_memory/api/routers/knowledge_router.py +17 -5
  14. basic_memory/api/routers/project_router.py +68 -14
  15. basic_memory/api/routers/resource_router.py +37 -27
  16. basic_memory/api/routers/utils.py +53 -14
  17. basic_memory/api/v2/__init__.py +35 -0
  18. basic_memory/api/v2/routers/__init__.py +21 -0
  19. basic_memory/api/v2/routers/directory_router.py +93 -0
  20. basic_memory/api/v2/routers/importer_router.py +181 -0
  21. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  22. basic_memory/api/v2/routers/memory_router.py +130 -0
  23. basic_memory/api/v2/routers/project_router.py +359 -0
  24. basic_memory/api/v2/routers/prompt_router.py +269 -0
  25. basic_memory/api/v2/routers/resource_router.py +286 -0
  26. basic_memory/api/v2/routers/search_router.py +73 -0
  27. basic_memory/cli/app.py +43 -7
  28. basic_memory/cli/auth.py +27 -4
  29. basic_memory/cli/commands/__init__.py +3 -1
  30. basic_memory/cli/commands/cloud/api_client.py +20 -5
  31. basic_memory/cli/commands/cloud/cloud_utils.py +13 -6
  32. basic_memory/cli/commands/cloud/rclone_commands.py +110 -14
  33. basic_memory/cli/commands/cloud/rclone_installer.py +18 -4
  34. basic_memory/cli/commands/cloud/upload.py +10 -3
  35. basic_memory/cli/commands/command_utils.py +52 -4
  36. basic_memory/cli/commands/db.py +78 -19
  37. basic_memory/cli/commands/format.py +198 -0
  38. basic_memory/cli/commands/import_chatgpt.py +12 -8
  39. basic_memory/cli/commands/import_claude_conversations.py +12 -8
  40. basic_memory/cli/commands/import_claude_projects.py +12 -8
  41. basic_memory/cli/commands/import_memory_json.py +12 -8
  42. basic_memory/cli/commands/mcp.py +8 -26
  43. basic_memory/cli/commands/project.py +22 -9
  44. basic_memory/cli/commands/status.py +3 -2
  45. basic_memory/cli/commands/telemetry.py +81 -0
  46. basic_memory/cli/container.py +84 -0
  47. basic_memory/cli/main.py +7 -0
  48. basic_memory/config.py +177 -77
  49. basic_memory/db.py +183 -77
  50. basic_memory/deps/__init__.py +293 -0
  51. basic_memory/deps/config.py +26 -0
  52. basic_memory/deps/db.py +56 -0
  53. basic_memory/deps/importers.py +200 -0
  54. basic_memory/deps/projects.py +238 -0
  55. basic_memory/deps/repositories.py +179 -0
  56. basic_memory/deps/services.py +480 -0
  57. basic_memory/deps.py +14 -409
  58. basic_memory/file_utils.py +212 -3
  59. basic_memory/ignore_utils.py +5 -5
  60. basic_memory/importers/base.py +40 -19
  61. basic_memory/importers/chatgpt_importer.py +17 -4
  62. basic_memory/importers/claude_conversations_importer.py +27 -12
  63. basic_memory/importers/claude_projects_importer.py +50 -14
  64. basic_memory/importers/memory_json_importer.py +36 -16
  65. basic_memory/importers/utils.py +5 -2
  66. basic_memory/markdown/entity_parser.py +62 -23
  67. basic_memory/markdown/markdown_processor.py +67 -4
  68. basic_memory/markdown/plugins.py +4 -2
  69. basic_memory/markdown/utils.py +10 -1
  70. basic_memory/mcp/async_client.py +1 -0
  71. basic_memory/mcp/clients/__init__.py +28 -0
  72. basic_memory/mcp/clients/directory.py +70 -0
  73. basic_memory/mcp/clients/knowledge.py +176 -0
  74. basic_memory/mcp/clients/memory.py +120 -0
  75. basic_memory/mcp/clients/project.py +89 -0
  76. basic_memory/mcp/clients/resource.py +71 -0
  77. basic_memory/mcp/clients/search.py +65 -0
  78. basic_memory/mcp/container.py +110 -0
  79. basic_memory/mcp/project_context.py +47 -33
  80. basic_memory/mcp/prompts/ai_assistant_guide.py +2 -2
  81. basic_memory/mcp/prompts/recent_activity.py +2 -2
  82. basic_memory/mcp/prompts/utils.py +3 -3
  83. basic_memory/mcp/server.py +58 -0
  84. basic_memory/mcp/tools/build_context.py +14 -14
  85. basic_memory/mcp/tools/canvas.py +34 -12
  86. basic_memory/mcp/tools/chatgpt_tools.py +4 -1
  87. basic_memory/mcp/tools/delete_note.py +31 -7
  88. basic_memory/mcp/tools/edit_note.py +14 -9
  89. basic_memory/mcp/tools/list_directory.py +7 -17
  90. basic_memory/mcp/tools/move_note.py +35 -31
  91. basic_memory/mcp/tools/project_management.py +29 -25
  92. basic_memory/mcp/tools/read_content.py +13 -3
  93. basic_memory/mcp/tools/read_note.py +24 -14
  94. basic_memory/mcp/tools/recent_activity.py +32 -38
  95. basic_memory/mcp/tools/search.py +17 -10
  96. basic_memory/mcp/tools/utils.py +28 -0
  97. basic_memory/mcp/tools/view_note.py +2 -1
  98. basic_memory/mcp/tools/write_note.py +37 -14
  99. basic_memory/models/knowledge.py +15 -2
  100. basic_memory/models/project.py +7 -1
  101. basic_memory/models/search.py +58 -2
  102. basic_memory/project_resolver.py +222 -0
  103. basic_memory/repository/entity_repository.py +210 -3
  104. basic_memory/repository/observation_repository.py +1 -0
  105. basic_memory/repository/postgres_search_repository.py +451 -0
  106. basic_memory/repository/project_repository.py +38 -1
  107. basic_memory/repository/relation_repository.py +58 -2
  108. basic_memory/repository/repository.py +1 -0
  109. basic_memory/repository/search_index_row.py +95 -0
  110. basic_memory/repository/search_repository.py +77 -615
  111. basic_memory/repository/search_repository_base.py +241 -0
  112. basic_memory/repository/sqlite_search_repository.py +437 -0
  113. basic_memory/runtime.py +61 -0
  114. basic_memory/schemas/base.py +36 -6
  115. basic_memory/schemas/directory.py +2 -1
  116. basic_memory/schemas/memory.py +9 -2
  117. basic_memory/schemas/project_info.py +2 -0
  118. basic_memory/schemas/response.py +84 -27
  119. basic_memory/schemas/search.py +5 -0
  120. basic_memory/schemas/sync_report.py +1 -1
  121. basic_memory/schemas/v2/__init__.py +27 -0
  122. basic_memory/schemas/v2/entity.py +133 -0
  123. basic_memory/schemas/v2/resource.py +47 -0
  124. basic_memory/services/context_service.py +219 -43
  125. basic_memory/services/directory_service.py +26 -11
  126. basic_memory/services/entity_service.py +68 -33
  127. basic_memory/services/file_service.py +131 -16
  128. basic_memory/services/initialization.py +51 -26
  129. basic_memory/services/link_resolver.py +1 -0
  130. basic_memory/services/project_service.py +68 -43
  131. basic_memory/services/search_service.py +75 -16
  132. basic_memory/sync/__init__.py +2 -1
  133. basic_memory/sync/coordinator.py +160 -0
  134. basic_memory/sync/sync_service.py +135 -115
  135. basic_memory/sync/watch_service.py +32 -12
  136. basic_memory/telemetry.py +249 -0
  137. basic_memory/utils.py +96 -75
  138. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/METADATA +129 -5
  139. basic_memory-0.17.4.dist-info/RECORD +193 -0
  140. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  141. basic_memory-0.16.1.dist-info/RECORD +0 -148
  142. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +0 -0
  143. {basic_memory-0.16.1.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,89 @@
1
+ """Typed client for project API operations.
2
+
3
+ Encapsulates project-level endpoints.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
11
+ from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
12
+
13
+
14
+ class ProjectClient:
15
+ """Typed client for project management operations.
16
+
17
+ Centralizes:
18
+ - API path construction for project endpoints
19
+ - Response validation via Pydantic models
20
+ - Consistent error handling through call_* utilities
21
+
22
+ Note: This client does not require a project_id since it operates
23
+ across projects.
24
+
25
+ Usage:
26
+ async with get_client() as http_client:
27
+ client = ProjectClient(http_client)
28
+ projects = await client.list_projects()
29
+ """
30
+
31
+ def __init__(self, http_client: AsyncClient):
32
+ """Initialize the project client.
33
+
34
+ Args:
35
+ http_client: HTTPX AsyncClient for making requests
36
+ """
37
+ self.http_client = http_client
38
+
39
+ async def list_projects(self) -> ProjectList:
40
+ """List all available projects.
41
+
42
+ Returns:
43
+ ProjectList with all projects and default project name
44
+
45
+ Raises:
46
+ ToolError: If the request fails
47
+ """
48
+ response = await call_get(
49
+ self.http_client,
50
+ "/projects/projects",
51
+ )
52
+ return ProjectList.model_validate(response.json())
53
+
54
+ async def create_project(self, project_data: dict[str, Any]) -> ProjectStatusResponse:
55
+ """Create a new project.
56
+
57
+ Args:
58
+ project_data: Project creation data (name, path, set_default)
59
+
60
+ Returns:
61
+ ProjectStatusResponse with creation result
62
+
63
+ Raises:
64
+ ToolError: If the request fails
65
+ """
66
+ response = await call_post(
67
+ self.http_client,
68
+ "/projects/projects",
69
+ json=project_data,
70
+ )
71
+ return ProjectStatusResponse.model_validate(response.json())
72
+
73
+ async def delete_project(self, project_external_id: str) -> ProjectStatusResponse:
74
+ """Delete a project by its external ID.
75
+
76
+ Args:
77
+ project_external_id: Project external ID (UUID)
78
+
79
+ Returns:
80
+ ProjectStatusResponse with deletion result
81
+
82
+ Raises:
83
+ ToolError: If the request fails
84
+ """
85
+ response = await call_delete(
86
+ self.http_client,
87
+ f"/v2/projects/{project_external_id}",
88
+ )
89
+ return ProjectStatusResponse.model_validate(response.json())
@@ -0,0 +1,71 @@
1
+ """Typed client for resource API operations.
2
+
3
+ Encapsulates all /v2/projects/{project_id}/resource/* endpoints.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ from httpx import AsyncClient, Response
9
+
10
+ from basic_memory.mcp.tools.utils import call_get
11
+
12
+
13
+ class ResourceClient:
14
+ """Typed client for resource operations.
15
+
16
+ Centralizes:
17
+ - API path construction for /v2/projects/{project_id}/resource/*
18
+ - Consistent error handling through call_* utilities
19
+
20
+ Note: This client returns raw Response objects for resources since they
21
+ may be text, images, or other binary content that needs special handling.
22
+
23
+ Usage:
24
+ async with get_client() as http_client:
25
+ client = ResourceClient(http_client, project_id)
26
+ response = await client.read(entity_id)
27
+ text = response.text
28
+ """
29
+
30
+ def __init__(self, http_client: AsyncClient, project_id: str):
31
+ """Initialize the resource client.
32
+
33
+ Args:
34
+ http_client: HTTPX AsyncClient for making requests
35
+ project_id: Project external_id (UUID) for API calls
36
+ """
37
+ self.http_client = http_client
38
+ self.project_id = project_id
39
+ self._base_path = f"/v2/projects/{project_id}/resource"
40
+
41
+ async def read(
42
+ self,
43
+ entity_id: str,
44
+ *,
45
+ page: Optional[int] = None,
46
+ page_size: Optional[int] = None,
47
+ ) -> Response:
48
+ """Read a resource by entity ID.
49
+
50
+ Args:
51
+ entity_id: Entity external_id (UUID)
52
+ page: Optional page number for paginated content
53
+ page_size: Optional page size for paginated content
54
+
55
+ Returns:
56
+ Raw HTTP Response (caller handles text/binary content)
57
+
58
+ Raises:
59
+ ToolError: If the resource is not found or request fails
60
+ """
61
+ params: dict = {}
62
+ if page is not None:
63
+ params["page"] = page
64
+ if page_size is not None:
65
+ params["page_size"] = page_size
66
+
67
+ return await call_get(
68
+ self.http_client,
69
+ f"{self._base_path}/{entity_id}",
70
+ params=params if params else None,
71
+ )
@@ -0,0 +1,65 @@
1
+ """Typed client for search API operations.
2
+
3
+ Encapsulates all /v2/projects/{project_id}/search/* endpoints.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from httpx import AsyncClient
9
+
10
+ from basic_memory.mcp.tools.utils import call_post
11
+ from basic_memory.schemas.search import SearchResponse
12
+
13
+
14
+ class SearchClient:
15
+ """Typed client for search operations.
16
+
17
+ Centralizes:
18
+ - API path construction for /v2/projects/{project_id}/search/*
19
+ - Response validation via Pydantic models
20
+ - Consistent error handling through call_* utilities
21
+
22
+ Usage:
23
+ async with get_client() as http_client:
24
+ client = SearchClient(http_client, project_id)
25
+ results = await client.search(search_query.model_dump())
26
+ """
27
+
28
+ def __init__(self, http_client: AsyncClient, project_id: str):
29
+ """Initialize the search client.
30
+
31
+ Args:
32
+ http_client: HTTPX AsyncClient for making requests
33
+ project_id: Project external_id (UUID) for API calls
34
+ """
35
+ self.http_client = http_client
36
+ self.project_id = project_id
37
+ self._base_path = f"/v2/projects/{project_id}/search"
38
+
39
+ async def search(
40
+ self,
41
+ query: dict[str, Any],
42
+ *,
43
+ page: int = 1,
44
+ page_size: int = 10,
45
+ ) -> SearchResponse:
46
+ """Search across all content in the knowledge base.
47
+
48
+ Args:
49
+ query: Search query dict (from SearchQuery.model_dump())
50
+ page: Page number (1-indexed)
51
+ page_size: Results per page
52
+
53
+ Returns:
54
+ SearchResponse with results and pagination
55
+
56
+ Raises:
57
+ ToolError: If the request fails
58
+ """
59
+ response = await call_post(
60
+ self.http_client,
61
+ f"{self._base_path}/",
62
+ json=query,
63
+ params={"page": page, "page_size": page_size},
64
+ )
65
+ return SearchResponse.model_validate(response.json())
@@ -0,0 +1,110 @@
1
+ """MCP composition root for Basic Memory.
2
+
3
+ This container owns reading ConfigManager and environment variables for the
4
+ MCP server entrypoint. Downstream modules receive config/dependencies explicitly
5
+ rather than reading globals.
6
+
7
+ Design principles:
8
+ - Only this module reads ConfigManager directly
9
+ - Runtime mode (cloud/local/test) is resolved here
10
+ - File sync decisions are centralized here
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING
15
+
16
+ from basic_memory.config import BasicMemoryConfig, ConfigManager
17
+ from basic_memory.runtime import RuntimeMode, resolve_runtime_mode
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from basic_memory.sync import SyncCoordinator
21
+
22
+
23
+ @dataclass
24
+ class McpContainer:
25
+ """Composition root for the MCP server entrypoint.
26
+
27
+ Holds resolved configuration and runtime context.
28
+ Created once at server startup, then used to wire dependencies.
29
+ """
30
+
31
+ config: BasicMemoryConfig
32
+ mode: RuntimeMode
33
+
34
+ @classmethod
35
+ def create(cls) -> "McpContainer":
36
+ """Create container by reading ConfigManager.
37
+
38
+ This is the single point where MCP reads global config.
39
+ """
40
+ config = ConfigManager().config
41
+ mode = resolve_runtime_mode(
42
+ cloud_mode_enabled=config.cloud_mode_enabled,
43
+ is_test_env=config.is_test_env,
44
+ )
45
+ return cls(config=config, mode=mode)
46
+
47
+ # --- Runtime Mode Properties ---
48
+
49
+ @property
50
+ def should_sync_files(self) -> bool:
51
+ """Whether local file sync should be started.
52
+
53
+ Sync is enabled when:
54
+ - sync_changes is True in config
55
+ - Not in test mode (tests manage their own sync)
56
+ - Not in cloud mode (cloud handles sync differently)
57
+ """
58
+ return (
59
+ self.config.sync_changes and not self.mode.is_test and not self.mode.is_cloud
60
+ )
61
+
62
+ @property
63
+ def sync_skip_reason(self) -> str | None:
64
+ """Reason why sync is skipped, or None if sync should run.
65
+
66
+ Useful for logging why sync was disabled.
67
+ """
68
+ if self.mode.is_test:
69
+ return "Test environment detected"
70
+ if self.mode.is_cloud:
71
+ return "Cloud mode enabled"
72
+ if not self.config.sync_changes:
73
+ return "Sync changes disabled"
74
+ return None
75
+
76
+ def create_sync_coordinator(self) -> "SyncCoordinator":
77
+ """Create a SyncCoordinator with this container's settings.
78
+
79
+ Returns:
80
+ SyncCoordinator configured for this runtime environment
81
+ """
82
+ # Deferred import to avoid circular dependency
83
+ from basic_memory.sync import SyncCoordinator
84
+
85
+ return SyncCoordinator(
86
+ config=self.config,
87
+ should_sync=self.should_sync_files,
88
+ skip_reason=self.sync_skip_reason,
89
+ )
90
+
91
+
92
+ # Module-level container instance (set by lifespan)
93
+ _container: McpContainer | None = None
94
+
95
+
96
+ def get_container() -> McpContainer:
97
+ """Get the current MCP container.
98
+
99
+ Raises:
100
+ RuntimeError: If container hasn't been initialized
101
+ """
102
+ if _container is None:
103
+ raise RuntimeError("MCP container not initialized. Call set_container() first.")
104
+ return _container
105
+
106
+
107
+ def set_container(container: McpContainer) -> None:
108
+ """Set the MCP container (called by lifespan)."""
109
+ global _container
110
+ _container = container
@@ -2,9 +2,12 @@
2
2
 
3
3
  Provides project lookup utilities for MCP tools.
4
4
  Handles project validation and context management in one place.
5
+
6
+ Note: This module uses ProjectResolver for unified project resolution.
7
+ The resolve_project_parameter function is a thin wrapper for backwards
8
+ compatibility with existing MCP tools.
5
9
  """
6
10
 
7
- import os
8
11
  from typing import Optional, List
9
12
  from httpx import AsyncClient
10
13
  from httpx._types import (
@@ -14,16 +17,26 @@ from loguru import logger
14
17
  from fastmcp import Context
15
18
 
16
19
  from basic_memory.config import ConfigManager
17
- from basic_memory.mcp.tools.utils import call_get
20
+ from basic_memory.project_resolver import ProjectResolver
18
21
  from basic_memory.schemas.project_info import ProjectItem, ProjectList
19
22
  from basic_memory.utils import generate_permalink
20
23
 
21
24
 
22
- async def resolve_project_parameter(project: Optional[str] = None) -> Optional[str]:
25
+ async def resolve_project_parameter(
26
+ project: Optional[str] = None,
27
+ allow_discovery: bool = False,
28
+ cloud_mode: Optional[bool] = None,
29
+ default_project_mode: Optional[bool] = None,
30
+ default_project: Optional[str] = None,
31
+ ) -> Optional[str]:
23
32
  """Resolve project parameter using three-tier hierarchy.
24
33
 
25
- if config.cloud_mode:
26
- project is required
34
+ This is a thin wrapper around ProjectResolver for backwards compatibility.
35
+ New code should consider using ProjectResolver directly for more detailed
36
+ resolution information.
37
+
38
+ if cloud_mode:
39
+ project is required (unless allow_discovery=True for tools that support discovery mode)
27
40
  else:
28
41
  Resolution order:
29
42
  1. Single Project Mode (--project cli arg, or BASIC_MEMORY_MCP_PROJECT env var) - highest priority
@@ -32,41 +45,39 @@ async def resolve_project_parameter(project: Optional[str] = None) -> Optional[s
32
45
 
33
46
  Args:
34
47
  project: Optional explicit project parameter
48
+ allow_discovery: If True, allows returning None in cloud mode for discovery mode
49
+ (used by tools like recent_activity that can operate across all projects)
50
+ cloud_mode: Optional explicit cloud mode. If not provided, reads from ConfigManager.
51
+ default_project_mode: Optional explicit default project mode. If not provided, reads from ConfigManager.
52
+ default_project: Optional explicit default project. If not provided, reads from ConfigManager.
35
53
 
36
54
  Returns:
37
55
  Resolved project name or None if no resolution possible
38
56
  """
39
-
40
- config = ConfigManager().config
41
- # if cloud_mode, project is required
42
- if config.cloud_mode:
43
- if project:
44
- logger.debug(f"project: {project}, cloud_mode: {config.cloud_mode}")
45
- return project
46
- else:
47
- raise ValueError("No project specified. Project is required for cloud mode.")
48
-
49
- # Priority 1: CLI constraint overrides everything (--project arg sets env var)
50
- constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
51
- if constrained_project:
52
- logger.debug(f"Using CLI constrained project: {constrained_project}")
53
- return constrained_project
54
-
55
- # Priority 2: Explicit project parameter
56
- if project:
57
- logger.debug(f"Using explicit project parameter: {project}")
58
- return project
59
-
60
- # Priority 3: Default project mode
61
- if config.default_project_mode:
62
- logger.debug(f"Using default project from config: {config.default_project}")
63
- return config.default_project
64
-
65
- # No resolution possible
66
- return None
57
+ # Load config for any values not explicitly provided
58
+ if cloud_mode is None or default_project_mode is None or default_project is None:
59
+ config = ConfigManager().config
60
+ if cloud_mode is None:
61
+ cloud_mode = config.cloud_mode
62
+ if default_project_mode is None:
63
+ default_project_mode = config.default_project_mode
64
+ if default_project is None:
65
+ default_project = config.default_project
66
+
67
+ # Create resolver with configuration and resolve
68
+ resolver = ProjectResolver.from_env(
69
+ cloud_mode=cloud_mode,
70
+ default_project_mode=default_project_mode,
71
+ default_project=default_project,
72
+ )
73
+ result = resolver.resolve(project=project, allow_discovery=allow_discovery)
74
+ return result.project
67
75
 
68
76
 
69
77
  async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = None) -> List[str]:
78
+ # Deferred import to avoid circular dependency with tools
79
+ from basic_memory.mcp.tools.utils import call_get
80
+
70
81
  response = await call_get(client, "/projects/projects", headers=headers)
71
82
  project_list = ProjectList.model_validate(response.json())
72
83
  return [project.name for project in project_list.projects]
@@ -92,6 +103,9 @@ async def get_active_project(
92
103
  ValueError: If no project can be resolved
93
104
  HTTPError: If project doesn't exist or is inaccessible
94
105
  """
106
+ # Deferred import to avoid circular dependency with tools
107
+ from basic_memory.mcp.tools.utils import call_get
108
+
95
109
  resolved_project = await resolve_project_parameter(project)
96
110
  if not resolved_project:
97
111
  project_names = await get_project_names(client, headers)
@@ -32,7 +32,7 @@ def ai_assistant_guide() -> str:
32
32
 
33
33
  # Add mode-specific header
34
34
  mode_info = ""
35
- if config.default_project_mode:
35
+ if config.default_project_mode: # pragma: no cover
36
36
  mode_info = f"""
37
37
  # 🎯 Default Project Mode Active
38
38
 
@@ -46,7 +46,7 @@ def ai_assistant_guide() -> str:
46
46
  ────────────────────────────────────────
47
47
 
48
48
  """
49
- else:
49
+ else: # pragma: no cover
50
50
  mode_info = """
51
51
  # 🔧 Multi-Project Mode Active
52
52
 
@@ -64,7 +64,7 @@ async def recent_activity_prompt(
64
64
  primary_results.append(item.primary_result)
65
65
  # Add up to 1 related result per primary item
66
66
  if item.related_results:
67
- related_results.extend(item.related_results[:1])
67
+ related_results.extend(item.related_results[:1]) # pragma: no cover
68
68
 
69
69
  # Limit total results for readability
70
70
  primary_results = primary_results[:8]
@@ -78,7 +78,7 @@ async def recent_activity_prompt(
78
78
  primary_results.append(item.primary_result)
79
79
  # Add up to 2 related results per primary item
80
80
  if item.related_results:
81
- related_results.extend(item.related_results[:2])
81
+ related_results.extend(item.related_results[:2]) # pragma: no cover
82
82
 
83
83
  # Set topic based on mode
84
84
  if project:
@@ -123,9 +123,9 @@ def format_prompt_context(context: PromptContext) -> str:
123
123
 
124
124
  # Add content snippet
125
125
  if hasattr(primary, "content") and primary.content: # pyright: ignore
126
- content = primary.content or "" # pyright: ignore
127
- if content:
128
- section += f"\n**Excerpt**:\n{content}\n"
126
+ content = primary.content or "" # pyright: ignore # pragma: no cover
127
+ if content: # pragma: no cover
128
+ section += f"\n**Excerpt**:\n{content}\n" # pragma: no cover
129
129
 
130
130
  section += dedent(f"""
131
131
 
@@ -2,8 +2,66 @@
2
2
  Basic Memory FastMCP server.
3
3
  """
4
4
 
5
+ from contextlib import asynccontextmanager
6
+
5
7
  from fastmcp import FastMCP
8
+ from loguru import logger
9
+
10
+ from basic_memory import db
11
+ from basic_memory.mcp.container import McpContainer, set_container
12
+ from basic_memory.services.initialization import initialize_app
13
+ from basic_memory.telemetry import show_notice_if_needed, track_app_started
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastMCP):
18
+ """Lifecycle manager for the MCP server.
19
+
20
+ Handles:
21
+ - Database initialization and migrations
22
+ - Telemetry notice and tracking
23
+ - File sync via SyncCoordinator (if enabled and not in cloud mode)
24
+ - Proper cleanup on shutdown
25
+ """
26
+ # --- Composition Root ---
27
+ # Create container and read config (single point of config access)
28
+ container = McpContainer.create()
29
+ set_container(container)
30
+
31
+ logger.info(f"Starting Basic Memory MCP server (mode={container.mode.name})")
32
+
33
+ # Show telemetry notice (first run only) and track startup
34
+ show_notice_if_needed()
35
+ track_app_started("mcp")
36
+
37
+ # Track if we created the engine (vs test fixtures providing it)
38
+ # This prevents disposing an engine provided by test fixtures when
39
+ # multiple Client connections are made in the same test
40
+ engine_was_none = db._engine is None
41
+
42
+ # Initialize app (runs migrations, reconciles projects)
43
+ await initialize_app(container.config)
44
+
45
+ # Create and start sync coordinator (lifecycle centralized in coordinator)
46
+ sync_coordinator = container.create_sync_coordinator()
47
+ await sync_coordinator.start()
48
+
49
+ try:
50
+ yield
51
+ finally:
52
+ # Shutdown - coordinator handles clean task cancellation
53
+ logger.info("Shutting down Basic Memory MCP server")
54
+ await sync_coordinator.stop()
55
+
56
+ # Only shutdown DB if we created it (not if test fixture provided it)
57
+ if engine_was_none:
58
+ await db.shutdown_db()
59
+ logger.info("Database connections closed")
60
+ else: # pragma: no cover
61
+ logger.debug("Skipping DB shutdown - engine provided externally")
62
+
6
63
 
7
64
  mcp = FastMCP(
8
65
  name="Basic Memory",
66
+ lifespan=lifespan,
9
67
  )
@@ -8,7 +8,7 @@ from fastmcp import Context
8
8
  from basic_memory.mcp.async_client import get_client
9
9
  from basic_memory.mcp.project_context import get_active_project
10
10
  from basic_memory.mcp.server import mcp
11
- from basic_memory.mcp.tools.utils import call_get
11
+ from basic_memory.telemetry import track_mcp_tool
12
12
  from basic_memory.schemas.base import TimeFrame
13
13
  from basic_memory.schemas.memory import (
14
14
  GraphContext,
@@ -87,6 +87,7 @@ async def build_context(
87
87
  Raises:
88
88
  ToolError: If project doesn't exist or depth parameter is invalid
89
89
  """
90
+ track_mcp_tool("build_context")
90
91
  logger.info(f"Building context from {url} in project {project}")
91
92
 
92
93
  # Convert string depth to integer if needed
@@ -104,17 +105,16 @@ async def build_context(
104
105
  # Get the active project using the new stateless approach
105
106
  active_project = await get_active_project(client, project, context)
106
107
 
107
- project_url = active_project.project_url
108
-
109
- response = await call_get(
110
- client,
111
- f"{project_url}/memory/{memory_url_path(url)}",
112
- params={
113
- "depth": depth,
114
- "timeframe": timeframe,
115
- "page": page,
116
- "page_size": page_size,
117
- "max_related": max_related,
118
- },
108
+ # Import here to avoid circular import
109
+ from basic_memory.mcp.clients import MemoryClient
110
+
111
+ # Use typed MemoryClient for API calls
112
+ memory_client = MemoryClient(client, active_project.external_id)
113
+ return await memory_client.build_context(
114
+ memory_url_path(url),
115
+ depth=depth or 1,
116
+ timeframe=timeframe,
117
+ page=page,
118
+ page_size=page_size,
119
+ max_related=max_related,
119
120
  )
120
- return GraphContext.model_validate(response.json())