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
@@ -12,7 +12,8 @@ from fastmcp import Context
12
12
  from basic_memory.mcp.async_client import get_client
13
13
  from basic_memory.mcp.project_context import get_active_project
14
14
  from basic_memory.mcp.server import mcp
15
- from basic_memory.mcp.tools.utils import call_put
15
+ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
16
+ from basic_memory.telemetry import track_mcp_tool
16
17
 
17
18
 
18
19
  @mcp.tool(
@@ -94,9 +95,9 @@ async def canvas(
94
95
  Raises:
95
96
  ToolError: If project doesn't exist or folder path is invalid
96
97
  """
98
+ track_mcp_tool("canvas")
97
99
  async with get_client() as client:
98
100
  active_project = await get_active_project(client, project, context)
99
- project_url = active_project.project_url
100
101
 
101
102
  # Ensure path has .canvas extension
102
103
  file_title = title if title.endswith(".canvas") else f"{title}.canvas"
@@ -108,23 +109,44 @@ async def canvas(
108
109
  # Convert to JSON
109
110
  canvas_json = json.dumps(canvas_data, indent=2)
110
111
 
111
- # Write the file using the resource API
112
+ # Try to create the canvas file first (optimistic create)
112
113
  logger.info(f"Creating canvas file: {file_path} in project {project}")
113
- # Send canvas_json as content string, not as json parameter
114
- # The resource endpoint expects Body() string content, not JSON-encoded data
115
- response = await call_put(
116
- client,
117
- f"{project_url}/resource/{file_path}",
118
- content=canvas_json,
119
- headers={"Content-Type": "text/plain"},
120
- )
114
+ try:
115
+ response = await call_post(
116
+ client,
117
+ f"/v2/projects/{active_project.external_id}/resource",
118
+ json={"file_path": file_path, "content": canvas_json},
119
+ )
120
+ action = "Created"
121
+ except Exception as e:
122
+ # If creation failed due to conflict (already exists), try to update
123
+ if (
124
+ "409" in str(e)
125
+ or "conflict" in str(e).lower()
126
+ or "already exists" in str(e).lower()
127
+ ):
128
+ logger.info(f"Canvas file exists, updating instead: {file_path}")
129
+ try:
130
+ entity_id = await resolve_entity_id(client, active_project.external_id, file_path)
131
+ # For update, send content in JSON body
132
+ response = await call_put(
133
+ client,
134
+ f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
135
+ json={"content": canvas_json},
136
+ )
137
+ action = "Updated"
138
+ except Exception as update_error: # pragma: no cover
139
+ # Re-raise the original error if update also fails
140
+ raise e from update_error # pragma: no cover
141
+ else:
142
+ # Re-raise if it's not a conflict error
143
+ raise # pragma: no cover
121
144
 
122
145
  # Parse response
123
146
  result = response.json()
124
147
  logger.debug(result)
125
148
 
126
149
  # Build summary
127
- action = "Created" if response.status_code == 201 else "Updated"
128
150
  summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
129
151
 
130
152
  return "\n".join(summary)
@@ -15,6 +15,7 @@ from basic_memory.mcp.tools.search import search_notes
15
15
  from basic_memory.mcp.tools.read_note import read_note
16
16
  from basic_memory.schemas.search import SearchResponse
17
17
  from basic_memory.config import ConfigManager
18
+ from basic_memory.telemetry import track_mcp_tool
18
19
 
19
20
 
20
21
  def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
@@ -55,7 +56,7 @@ def _format_document_for_chatgpt(
55
56
  title = "Untitled Document"
56
57
 
57
58
  # Handle error cases
58
- if isinstance(content, str) and content.startswith("# Note Not Found"):
59
+ if isinstance(content, str) and content.lstrip().startswith("# Note Not Found"):
59
60
  return {
60
61
  "id": identifier,
61
62
  "title": title or "Document Not Found",
@@ -88,6 +89,7 @@ async def search(
88
89
  List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
89
90
  where the JSON body contains `results`, `total_count`, and echo of `query`.
90
91
  """
92
+ track_mcp_tool("search")
91
93
  logger.info(f"ChatGPT search request: query='{query}'")
92
94
 
93
95
  try:
@@ -151,6 +153,7 @@ async def fetch(
151
153
  List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
152
154
  where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
153
155
  """
156
+ track_mcp_tool("fetch")
154
157
  logger.info(f"ChatGPT fetch request: id='{id}'")
155
158
 
156
159
  try:
@@ -3,12 +3,12 @@ from typing import Optional
3
3
 
4
4
  from loguru import logger
5
5
  from fastmcp import Context
6
+ from mcp.server.fastmcp.exceptions import ToolError
6
7
 
7
8
  from basic_memory.mcp.project_context import get_active_project
8
- from basic_memory.mcp.tools.utils import call_delete
9
9
  from basic_memory.mcp.server import mcp
10
10
  from basic_memory.mcp.async_client import get_client
11
- from basic_memory.schemas import DeleteEntitiesResponse
11
+ from basic_memory.telemetry import track_mcp_tool
12
12
 
13
13
 
14
14
  def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str:
@@ -202,13 +202,35 @@ async def delete_note(
202
202
  with suggestions for finding the correct identifier, including search
203
203
  commands and alternative formats to try.
204
204
  """
205
+ track_mcp_tool("delete_note")
205
206
  async with get_client() as client:
206
207
  active_project = await get_active_project(client, project, context)
207
- project_url = active_project.project_url
208
+
209
+ # Import here to avoid circular import
210
+ from basic_memory.mcp.clients import KnowledgeClient
211
+
212
+ # Use typed KnowledgeClient for API calls
213
+ knowledge_client = KnowledgeClient(client, active_project.external_id)
208
214
 
209
215
  try:
210
- response = await call_delete(client, f"{project_url}/knowledge/entities/{identifier}")
211
- result = DeleteEntitiesResponse.model_validate(response.json())
216
+ # Resolve identifier to entity ID
217
+ entity_id = await knowledge_client.resolve_entity(identifier)
218
+ except ToolError as e:
219
+ # If entity not found, return False (note doesn't exist)
220
+ if "Entity not found" in str(e) or "not found" in str(e).lower():
221
+ logger.warning(f"Note not found for deletion: {identifier}")
222
+ return False
223
+ # For other resolution errors, return formatted error message
224
+ logger.error( # pragma: no cover
225
+ f"Delete failed for '{identifier}': {e}, project: {active_project.name}"
226
+ )
227
+ return _format_delete_error_response( # pragma: no cover
228
+ active_project.name, str(e), identifier
229
+ )
230
+
231
+ try:
232
+ # Call the DELETE endpoint
233
+ result = await knowledge_client.delete_entity(entity_id)
212
234
 
213
235
  if result.deleted:
214
236
  logger.info(
@@ -216,8 +238,10 @@ async def delete_note(
216
238
  )
217
239
  return True
218
240
  else:
219
- logger.warning(f"Delete operation completed but note was not deleted: {identifier}")
220
- return False
241
+ logger.warning( # pragma: no cover
242
+ f"Delete operation completed but note was not deleted: {identifier}"
243
+ )
244
+ return False # pragma: no cover
221
245
 
222
246
  except Exception as e: # pragma: no cover
223
247
  logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}")
@@ -8,8 +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, add_project_metadata
10
10
  from basic_memory.mcp.server import mcp
11
- from basic_memory.mcp.tools.utils import call_patch
12
- from basic_memory.schemas import EntityResponse
11
+ from basic_memory.telemetry import track_mcp_tool
13
12
 
14
13
 
15
14
  def _format_error_response(
@@ -214,9 +213,9 @@ async def edit_note(
214
213
  search_notes() first to find the correct identifier. The tool provides detailed
215
214
  error messages with suggestions if operations fail.
216
215
  """
216
+ track_mcp_tool("edit_note")
217
217
  async with get_client() as client:
218
218
  active_project = await get_active_project(client, project, context)
219
- project_url = active_project.project_url
220
219
 
221
220
  logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
222
221
 
@@ -235,6 +234,15 @@ async def edit_note(
235
234
 
236
235
  # Use the PATCH endpoint to edit the entity
237
236
  try:
237
+ # Import here to avoid circular import
238
+ from basic_memory.mcp.clients import KnowledgeClient
239
+
240
+ # Use typed KnowledgeClient for API calls
241
+ knowledge_client = KnowledgeClient(client, active_project.external_id)
242
+
243
+ # Resolve identifier to entity ID
244
+ entity_id = await knowledge_client.resolve_entity(identifier)
245
+
238
246
  # Prepare the edit request data
239
247
  edit_data = {
240
248
  "operation": operation,
@@ -250,9 +258,7 @@ async def edit_note(
250
258
  edit_data["expected_replacements"] = str(expected_replacements)
251
259
 
252
260
  # Call the PATCH endpoint
253
- url = f"{project_url}/knowledge/entities/{identifier}"
254
- response = await call_patch(client, url, json=edit_data)
255
- result = EntityResponse.model_validate(response.json())
261
+ result = await knowledge_client.patch_entity(entity_id, edit_data)
256
262
 
257
263
  # Format summary
258
264
  summary = [
@@ -307,11 +313,10 @@ async def edit_note(
307
313
  permalink=result.permalink,
308
314
  observations_count=len(result.observations),
309
315
  relations_count=len(result.relations),
310
- status_code=response.status_code,
311
316
  )
312
317
 
313
- result = "\n".join(summary)
314
- return add_project_metadata(result, active_project.name)
318
+ summary_result = "\n".join(summary)
319
+ return add_project_metadata(summary_result, active_project.name)
315
320
 
316
321
  except Exception as e:
317
322
  logger.error(f"Error editing note: {e}")
@@ -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
 
13
13
 
14
14
  @mcp.tool(
@@ -63,30 +63,20 @@ async def list_directory(
63
63
  Raises:
64
64
  ToolError: If project doesn't exist or directory path is invalid
65
65
  """
66
+ track_mcp_tool("list_directory")
66
67
  async with get_client() as client:
67
68
  active_project = await get_active_project(client, project, context)
68
- project_url = active_project.project_url
69
-
70
- # Prepare query parameters
71
- params = {
72
- "dir_name": dir_name,
73
- "depth": str(depth),
74
- }
75
- if file_name_glob:
76
- params["file_name_glob"] = file_name_glob
77
69
 
78
70
  logger.debug(
79
71
  f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
80
72
  )
81
73
 
82
- # Call the API endpoint
83
- response = await call_get(
84
- client,
85
- f"{project_url}/directory/list",
86
- params=params,
87
- )
74
+ # Import here to avoid circular import
75
+ from basic_memory.mcp.clients import DirectoryClient
88
76
 
89
- nodes = response.json()
77
+ # Use typed DirectoryClient for API calls
78
+ directory_client = DirectoryClient(client, active_project.external_id)
79
+ nodes = await directory_client.list(dir_name, depth=depth, file_name_glob=file_name_glob)
90
80
 
91
81
  if not nodes:
92
82
  filter_desc = ""
@@ -8,10 +8,8 @@ from fastmcp import Context
8
8
 
9
9
  from basic_memory.mcp.async_client import get_client
10
10
  from basic_memory.mcp.server import mcp
11
- from basic_memory.mcp.tools.utils import call_post, call_get
12
11
  from basic_memory.mcp.project_context import get_active_project
13
- from basic_memory.schemas import EntityResponse
14
- from basic_memory.schemas.project_info import ProjectList
12
+ from basic_memory.telemetry import track_mcp_tool
15
13
  from basic_memory.utils import validate_project_path
16
14
 
17
15
 
@@ -30,9 +28,12 @@ async def _detect_cross_project_move_attempt(
30
28
  Error message with guidance if cross-project move is detected, None otherwise
31
29
  """
32
30
  try:
33
- # Get list of all available projects to check against
34
- response = await call_get(client, "/projects/projects")
35
- project_list = ProjectList.model_validate(response.json())
31
+ # Import here to avoid circular import
32
+ from basic_memory.mcp.clients import ProjectClient
33
+
34
+ # Use typed ProjectClient for API calls
35
+ project_client = ProjectClient(client)
36
+ project_list = await project_client.list_projects()
36
37
  project_names = [p.name.lower() for p in project_list.projects]
37
38
 
38
39
  # Check if destination path contains any project names
@@ -104,11 +105,12 @@ def _format_potential_cross_project_guidance(
104
105
  identifier: str, destination_path: str, current_project: str, available_projects: list[str]
105
106
  ) -> str:
106
107
  """Format guidance for potentially cross-project moves."""
107
- other_projects = ", ".join(available_projects[:3]) # Show first 3 projects
108
- if len(available_projects) > 3:
109
- other_projects += f" (and {len(available_projects) - 3} others)"
108
+ other_projects = ", ".join(available_projects[:3]) # Show first 3 projects # pragma: no cover
109
+ if len(available_projects) > 3: # pragma: no cover
110
+ other_projects += f" (and {len(available_projects) - 3} others)" # pragma: no cover
110
111
 
111
- return dedent(f"""
112
+ return ( # pragma: no cover
113
+ dedent(f"""
112
114
  # Move Failed - Check Project Context
113
115
 
114
116
  Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
@@ -139,6 +141,7 @@ def _format_potential_cross_project_guidance(
139
141
  list_memory_projects()
140
142
  ```
141
143
  """).strip()
144
+ )
142
145
 
143
146
 
144
147
  def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
@@ -302,9 +305,10 @@ delete_note("{identifier}")
302
305
  ```"""
303
306
 
304
307
  # Generic fallback
305
- return f"""# Move Failed
308
+ return ( # pragma: no cover
309
+ f"""# Move Failed
306
310
 
307
- Error moving '{identifier}' to '{destination_path}': {error_message}
311
+ Error moving '{identifier}' to '{destination_path}': {error_message} # pragma: no cover
308
312
 
309
313
  ## General troubleshooting:
310
314
  1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
@@ -335,6 +339,7 @@ write_note("Title", content, "target-folder")
335
339
  # Delete original once confirmed
336
340
  delete_note("{identifier}")
337
341
  ```"""
342
+ )
338
343
 
339
344
 
340
345
  @mcp.tool(
@@ -395,11 +400,11 @@ async def move_note(
395
400
  - Re-indexes the entity for search
396
401
  - Maintains all observations and relations
397
402
  """
403
+ track_mcp_tool("move_note")
398
404
  async with get_client() as client:
399
405
  logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
400
406
 
401
407
  active_project = await get_active_project(client, project, context)
402
- project_url = active_project.project_url
403
408
 
404
409
  # Validate destination path to prevent path traversal attacks
405
410
  project_path = active_project.home
@@ -431,13 +436,19 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
431
436
  logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
432
437
  return cross_project_error
433
438
 
439
+ # Import here to avoid circular import
440
+ from basic_memory.mcp.clients import KnowledgeClient
441
+
442
+ # Use typed KnowledgeClient for API calls
443
+ knowledge_client = KnowledgeClient(client, active_project.external_id)
444
+
434
445
  # Get the source entity information for extension validation
435
446
  source_ext = "md" # Default to .md if we can't determine source extension
436
447
  try:
448
+ # Resolve identifier to entity ID
449
+ entity_id = await knowledge_client.resolve_entity(identifier)
437
450
  # Fetch source entity information to get the current file extension
438
- url = f"{project_url}/knowledge/entities/{identifier}"
439
- response = await call_get(client, url)
440
- source_entity = EntityResponse.model_validate(response.json())
451
+ source_entity = await knowledge_client.get_entity(entity_id)
441
452
  if "." in source_entity.file_path:
442
453
  source_ext = source_entity.file_path.split(".")[-1]
443
454
  except Exception as e:
@@ -467,10 +478,10 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
467
478
 
468
479
  # Get the source entity to check its file extension
469
480
  try:
481
+ # Resolve identifier to entity ID (might already be cached from above)
482
+ entity_id = await knowledge_client.resolve_entity(identifier)
470
483
  # Fetch source entity information
471
- url = f"{project_url}/knowledge/entities/{identifier}"
472
- response = await call_get(client, url)
473
- source_entity = EntityResponse.model_validate(response.json())
484
+ source_entity = await knowledge_client.get_entity(entity_id)
474
485
 
475
486
  # Extract file extensions
476
487
  source_ext = (
@@ -505,17 +516,11 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
505
516
  logger.debug(f"Could not fetch source entity for extension check: {e}")
506
517
 
507
518
  try:
508
- # Prepare move request
509
- move_data = {
510
- "identifier": identifier,
511
- "destination_path": destination_path,
512
- "project": active_project.name,
513
- }
514
-
515
- # Call the move API endpoint
516
- url = f"{project_url}/knowledge/move"
517
- response = await call_post(client, url, json=move_data)
518
- result = EntityResponse.model_validate(response.json())
519
+ # Resolve identifier to entity ID for the move operation
520
+ entity_id = await knowledge_client.resolve_entity(identifier)
521
+
522
+ # Call the move API using KnowledgeClient
523
+ result = await knowledge_client.move_entity(entity_id, destination_path)
519
524
 
520
525
  # Build success message
521
526
  result_lines = [
@@ -534,7 +539,6 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
534
539
  identifier=identifier,
535
540
  destination_path=destination_path,
536
541
  project=active_project.name,
537
- status_code=response.status_code,
538
542
  )
539
543
 
540
544
  return "\n".join(result_lines)
@@ -9,12 +9,8 @@ from fastmcp import Context
9
9
 
10
10
  from basic_memory.mcp.async_client import get_client
11
11
  from basic_memory.mcp.server import mcp
12
- from basic_memory.mcp.tools.utils import call_get, call_post, call_delete
13
- from basic_memory.schemas.project_info import (
14
- ProjectList,
15
- ProjectStatusResponse,
16
- ProjectInfoRequest,
17
- )
12
+ from basic_memory.schemas.project_info import ProjectInfoRequest
13
+ from basic_memory.telemetry import track_mcp_tool
18
14
  from basic_memory.utils import generate_permalink
19
15
 
20
16
 
@@ -40,6 +36,7 @@ async def list_memory_projects(context: Context | None = None) -> str:
40
36
  Example:
41
37
  list_memory_projects()
42
38
  """
39
+ track_mcp_tool("list_memory_projects")
43
40
  async with get_client() as client:
44
41
  if context: # pragma: no cover
45
42
  await context.info("Listing all available projects")
@@ -47,9 +44,12 @@ async def list_memory_projects(context: Context | None = None) -> str:
47
44
  # Check if server is constrained to a specific project
48
45
  constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
49
46
 
50
- # Get projects from API
51
- response = await call_get(client, "/projects/projects")
52
- project_list = ProjectList.model_validate(response.json())
47
+ # Import here to avoid circular import
48
+ from basic_memory.mcp.clients import ProjectClient
49
+
50
+ # Use typed ProjectClient for API calls
51
+ project_client = ProjectClient(client)
52
+ project_list = await project_client.list_projects()
53
53
 
54
54
  if constrained_project:
55
55
  result = f"Project: {constrained_project}\n\n"
@@ -92,6 +92,7 @@ async def create_memory_project(
92
92
  create_memory_project("my-research", "~/Documents/research")
93
93
  create_memory_project("work-notes", "/home/user/work", set_default=True)
94
94
  """
95
+ track_mcp_tool("create_memory_project")
95
96
  async with get_client() as client:
96
97
  # Check if server is constrained to a specific project
97
98
  constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -106,9 +107,12 @@ async def create_memory_project(
106
107
  name=project_name, path=project_path, set_default=set_default
107
108
  )
108
109
 
109
- # Call API to create project
110
- response = await call_post(client, "/projects/projects", json=project_request.model_dump())
111
- status_response = ProjectStatusResponse.model_validate(response.json())
110
+ # Import here to avoid circular import
111
+ from basic_memory.mcp.clients import ProjectClient
112
+
113
+ # Use typed ProjectClient for API calls
114
+ project_client = ProjectClient(client)
115
+ status_response = await project_client.create_project(project_request.model_dump())
112
116
 
113
117
  result = f"✓ {status_response.message}\n\n"
114
118
 
@@ -147,6 +151,7 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
147
151
  This action cannot be undone. The project will need to be re-added
148
152
  to access its content through Basic Memory again.
149
153
  """
154
+ track_mcp_tool("delete_project")
150
155
  async with get_client() as client:
151
156
  # Check if server is constrained to a specific project
152
157
  constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
@@ -156,11 +161,18 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
156
161
  if context: # pragma: no cover
157
162
  await context.info(f"Deleting project: {project_name}")
158
163
 
164
+ # Import here to avoid circular import
165
+ from basic_memory.mcp.clients import ProjectClient
166
+
167
+ # Use typed ProjectClient for API calls
168
+ project_client = ProjectClient(client)
169
+
159
170
  # Get project info before deletion to validate it exists
160
- response = await call_get(client, "/projects/projects")
161
- project_list = ProjectList.model_validate(response.json())
171
+ project_list = await project_client.list_projects()
162
172
 
163
- # Find the project by name (case-insensitive) or permalink - same logic as switch_project
173
+ # Find the project by permalink (derived from name).
174
+ # Note: The API response uses `ProjectItem` which derives `permalink` from `name`,
175
+ # so a separate case-insensitive name match would be redundant here.
164
176
  project_permalink = generate_permalink(project_name)
165
177
  target_project = None
166
178
  for p in project_list.projects:
@@ -168,10 +180,6 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
168
180
  if p.permalink == project_permalink:
169
181
  target_project = p
170
182
  break
171
- # Also match by name comparison (case-insensitive)
172
- if p.name.lower() == project_name.lower():
173
- target_project = p
174
- break
175
183
 
176
184
  if not target_project:
177
185
  available_projects = [p.name for p in project_list.projects]
@@ -179,12 +187,8 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
179
187
  f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
180
188
  )
181
189
 
182
- # Call API to delete project using URL encoding for special characters
183
- from urllib.parse import quote
184
-
185
- encoded_name = quote(target_project.name, safe="")
186
- response = await call_delete(client, f"/projects/{encoded_name}")
187
- status_response = ProjectStatusResponse.model_validate(response.json())
190
+ # Delete project using project external_id
191
+ status_response = await project_client.delete_project(target_project.external_id)
188
192
 
189
193
  result = f"✓ {status_response.message}\n\n"
190
194
 
@@ -13,12 +13,14 @@ from typing import Optional
13
13
  from loguru import logger
14
14
  from PIL import Image as PILImage
15
15
  from fastmcp import Context
16
+ from mcp.server.fastmcp.exceptions import ToolError
16
17
 
17
18
  from basic_memory.mcp.project_context import get_active_project
18
19
  from basic_memory.mcp.server import mcp
19
20
  from basic_memory.mcp.async_client import get_client
20
- from basic_memory.mcp.tools.utils import call_get
21
+ from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
21
22
  from basic_memory.schemas.memory import memory_url_path
23
+ from basic_memory.telemetry import track_mcp_tool
22
24
  from basic_memory.utils import validate_project_path
23
25
 
24
26
 
@@ -199,11 +201,11 @@ async def read_content(
199
201
  HTTPError: If project doesn't exist or is inaccessible
200
202
  SecurityError: If path attempts path traversal
201
203
  """
204
+ track_mcp_tool("read_content")
202
205
  logger.info("Reading file", path=path, project=project)
203
206
 
204
207
  async with get_client() as client:
205
208
  active_project = await get_active_project(client, project, context)
206
- project_url = active_project.project_url
207
209
 
208
210
  url = memory_url_path(path)
209
211
 
@@ -221,7 +223,15 @@ async def read_content(
221
223
  "error": f"Path '{path}' is not allowed - paths must stay within project boundaries",
222
224
  }
223
225
 
224
- response = await call_get(client, f"{project_url}/resource/{url}")
226
+ # Resolve path to entity ID
227
+ try:
228
+ entity_id = await resolve_entity_id(client, active_project.external_id, url)
229
+ except ToolError:
230
+ # Convert resolution errors to "Resource not found" for consistency
231
+ raise ToolError(f"Resource not found: {url}")
232
+
233
+ # Call the v2 resource endpoint
234
+ response = await call_get(client, f"/v2/projects/{active_project.external_id}/resource/{entity_id}")
225
235
  content_type = response.headers.get("content-type", "application/octet-stream")
226
236
  content_length = int(response.headers.get("content-length", 0))
227
237