basic-memory 0.7.0__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 (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,11 @@
1
+ """Utility functions for making HTTP requests in Basic Memory MCP tools.
2
+
3
+ These functions provide a consistent interface for making HTTP requests
4
+ to the Basic Memory API, with improved error handling and logging.
5
+ """
6
+
1
7
  import typing
8
+ from typing import Optional
2
9
 
3
10
  from httpx import Response, URL, AsyncClient, HTTPStatusError
4
11
  from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
@@ -17,6 +24,56 @@ from loguru import logger
17
24
  from mcp.server.fastmcp.exceptions import ToolError
18
25
 
19
26
 
27
+ def get_error_message(
28
+ status_code: int, url: URL | str, method: str, msg: Optional[str] = None
29
+ ) -> str:
30
+ """Get a friendly error message based on the HTTP status code.
31
+
32
+ Args:
33
+ status_code: The HTTP status code
34
+ url: The URL that was requested
35
+ method: The HTTP method used
36
+
37
+ Returns:
38
+ A user-friendly error message
39
+ """
40
+ # Extract path from URL for cleaner error messages
41
+ if isinstance(url, str):
42
+ path = url.split("/")[-1]
43
+ else:
44
+ path = str(url).split("/")[-1] if url else "resource"
45
+
46
+ # Client errors (400-499)
47
+ if status_code == 400:
48
+ return f"Invalid request: The request to '{path}' was malformed or invalid"
49
+ elif status_code == 401: # pragma: no cover
50
+ return f"Authentication required: You need to authenticate to access '{path}'"
51
+ elif status_code == 403: # pragma: no cover
52
+ return f"Access denied: You don't have permission to access '{path}'"
53
+ elif status_code == 404:
54
+ return f"Resource not found: '{path}' doesn't exist or has been moved"
55
+ elif status_code == 409: # pragma: no cover
56
+ return f"Conflict: The request for '{path}' conflicts with the current state"
57
+ elif status_code == 429: # pragma: no cover
58
+ return "Too many requests: Please slow down and try again later"
59
+ elif 400 <= status_code < 500: # pragma: no cover
60
+ return f"Client error ({status_code}): The request for '{path}' could not be completed"
61
+
62
+ # Server errors (500-599)
63
+ elif status_code == 500:
64
+ return f"Internal server error: Something went wrong processing '{path}'"
65
+ elif status_code == 503: # pragma: no cover
66
+ return (
67
+ f"Service unavailable: The server is currently unable to handle requests for '{path}'"
68
+ )
69
+ elif 500 <= status_code < 600: # pragma: no cover
70
+ return f"Server error ({status_code}): The server encountered an error handling '{path}'"
71
+
72
+ # Fallback for any other status code
73
+ else: # pragma: no cover
74
+ return f"HTTP error {status_code}: {method} request to '{path}' failed"
75
+
76
+
20
77
  async def call_get(
21
78
  client: AsyncClient,
22
79
  url: URL | str,
@@ -29,7 +86,28 @@ async def call_get(
29
86
  timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
30
87
  extensions: RequestExtensions | None = None,
31
88
  ) -> Response:
89
+ """Make a GET request and handle errors appropriately.
90
+
91
+ Args:
92
+ client: The HTTPX AsyncClient to use
93
+ url: The URL to request
94
+ params: Query parameters
95
+ headers: HTTP headers
96
+ cookies: HTTP cookies
97
+ auth: Authentication
98
+ follow_redirects: Whether to follow redirects
99
+ timeout: Request timeout
100
+ extensions: HTTPX extensions
101
+
102
+ Returns:
103
+ The HTTP response
104
+
105
+ Raises:
106
+ ToolError: If the request fails with an appropriate error message
107
+ """
32
108
  logger.debug(f"Calling GET '{url}' params: '{params}'")
109
+ error_message = None
110
+
33
111
  try:
34
112
  response = await client.get(
35
113
  url,
@@ -41,11 +119,36 @@ async def call_get(
41
119
  timeout=timeout,
42
120
  extensions=extensions,
43
121
  )
44
- response.raise_for_status()
45
- return response
122
+
123
+ if response.is_success:
124
+ return response
125
+
126
+ # Handle different status codes differently
127
+ status_code = response.status_code
128
+ # get the message if available
129
+ response_data = response.json()
130
+ if isinstance(response_data, dict) and "detail" in response_data:
131
+ error_message = response_data["detail"]
132
+ else:
133
+ error_message = get_error_message(status_code, url, "PUT")
134
+
135
+ # Log at appropriate level based on status code
136
+ if 400 <= status_code < 500:
137
+ # Client errors: log as info except for 429 (Too Many Requests)
138
+ if status_code == 429: # pragma: no cover
139
+ logger.warning(f"Rate limit exceeded: GET {url}: {error_message}")
140
+ else:
141
+ logger.info(f"Client error: GET {url}: {error_message}")
142
+ else: # pragma: no cover
143
+ # Server errors: log as error
144
+ logger.error(f"Server error: GET {url}: {error_message}")
145
+
146
+ # Raise a tool error with the friendly message
147
+ response.raise_for_status() # Will always raise since we're in the error case
148
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
149
+
46
150
  except HTTPStatusError as e:
47
- logger.error(f"Error calling GET {url}: {e}")
48
- raise ToolError(f"Error calling tool: {e}.") from e
151
+ raise ToolError(error_message) from e
49
152
 
50
153
 
51
154
  async def call_put(
@@ -64,6 +167,32 @@ async def call_put(
64
167
  timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
65
168
  extensions: RequestExtensions | None = None,
66
169
  ) -> Response:
170
+ """Make a PUT request and handle errors appropriately.
171
+
172
+ Args:
173
+ client: The HTTPX AsyncClient to use
174
+ url: The URL to request
175
+ content: Request content
176
+ data: Form data
177
+ files: Files to upload
178
+ json: JSON data
179
+ params: Query parameters
180
+ headers: HTTP headers
181
+ cookies: HTTP cookies
182
+ auth: Authentication
183
+ follow_redirects: Whether to follow redirects
184
+ timeout: Request timeout
185
+ extensions: HTTPX extensions
186
+
187
+ Returns:
188
+ The HTTP response
189
+
190
+ Raises:
191
+ ToolError: If the request fails with an appropriate error message
192
+ """
193
+ logger.debug(f"Calling PUT '{url}'")
194
+ error_message = None
195
+
67
196
  try:
68
197
  response = await client.put(
69
198
  url,
@@ -79,11 +208,141 @@ async def call_put(
79
208
  timeout=timeout,
80
209
  extensions=extensions,
81
210
  )
82
- response.raise_for_status()
83
- return response
211
+
212
+ if response.is_success:
213
+ return response
214
+
215
+ # Handle different status codes differently
216
+ status_code = response.status_code
217
+
218
+ # get the message if available
219
+ response_data = response.json()
220
+ if isinstance(response_data, dict) and "detail" in response_data:
221
+ error_message = response_data["detail"] # pragma: no cover
222
+ else:
223
+ error_message = get_error_message(status_code, url, "PUT")
224
+
225
+ # Log at appropriate level based on status code
226
+ if 400 <= status_code < 500:
227
+ # Client errors: log as info except for 429 (Too Many Requests)
228
+ if status_code == 429: # pragma: no cover
229
+ logger.warning(f"Rate limit exceeded: PUT {url}: {error_message}")
230
+ else:
231
+ logger.info(f"Client error: PUT {url}: {error_message}")
232
+ else: # pragma: no cover
233
+ # Server errors: log as error
234
+ logger.error(f"Server error: PUT {url}: {error_message}")
235
+
236
+ # Raise a tool error with the friendly message
237
+ response.raise_for_status() # Will always raise since we're in the error case
238
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
239
+
84
240
  except HTTPStatusError as e:
85
- logger.error(f"Error calling PUT {url}: {e}")
86
- raise ToolError(f"Error calling tool: {e}") from e
241
+ raise ToolError(error_message) from e
242
+
243
+
244
+ async def call_patch(
245
+ client: AsyncClient,
246
+ url: URL | str,
247
+ *,
248
+ content: RequestContent | None = None,
249
+ data: RequestData | None = None,
250
+ files: RequestFiles | None = None,
251
+ json: typing.Any | None = None,
252
+ params: QueryParamTypes | None = None,
253
+ headers: HeaderTypes | None = None,
254
+ cookies: CookieTypes | None = None,
255
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
256
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
257
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
258
+ extensions: RequestExtensions | None = None,
259
+ ) -> Response:
260
+ """Make a PATCH request and handle errors appropriately.
261
+
262
+ Args:
263
+ client: The HTTPX AsyncClient to use
264
+ url: The URL to request
265
+ content: Request content
266
+ data: Form data
267
+ files: Files to upload
268
+ json: JSON data
269
+ params: Query parameters
270
+ headers: HTTP headers
271
+ cookies: HTTP cookies
272
+ auth: Authentication
273
+ follow_redirects: Whether to follow redirects
274
+ timeout: Request timeout
275
+ extensions: HTTPX extensions
276
+
277
+ Returns:
278
+ The HTTP response
279
+
280
+ Raises:
281
+ ToolError: If the request fails with an appropriate error message
282
+ """
283
+ logger.debug(f"Calling PATCH '{url}'")
284
+
285
+ try:
286
+ response = await client.patch(
287
+ url,
288
+ content=content,
289
+ data=data,
290
+ files=files,
291
+ json=json,
292
+ params=params,
293
+ headers=headers,
294
+ cookies=cookies,
295
+ auth=auth,
296
+ follow_redirects=follow_redirects,
297
+ timeout=timeout,
298
+ extensions=extensions,
299
+ )
300
+
301
+ if response.is_success:
302
+ return response
303
+
304
+ # Handle different status codes differently
305
+ status_code = response.status_code
306
+
307
+ # Try to extract specific error message from response body
308
+ try:
309
+ response_data = response.json()
310
+ if isinstance(response_data, dict) and "detail" in response_data:
311
+ error_message = response_data["detail"]
312
+ else:
313
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
314
+ except Exception: # pragma: no cover
315
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
316
+
317
+ # Log at appropriate level based on status code
318
+ if 400 <= status_code < 500:
319
+ # Client errors: log as info except for 429 (Too Many Requests)
320
+ if status_code == 429: # pragma: no cover
321
+ logger.warning(f"Rate limit exceeded: PATCH {url}: {error_message}")
322
+ else:
323
+ logger.info(f"Client error: PATCH {url}: {error_message}")
324
+ else: # pragma: no cover
325
+ # Server errors: log as error
326
+ logger.error(f"Server error: PATCH {url}: {error_message}") # pragma: no cover
327
+
328
+ # Raise a tool error with the friendly message
329
+ response.raise_for_status() # Will always raise since we're in the error case
330
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
331
+
332
+ except HTTPStatusError as e:
333
+ status_code = e.response.status_code
334
+
335
+ # Try to extract specific error message from response body
336
+ try:
337
+ response_data = e.response.json()
338
+ if isinstance(response_data, dict) and "detail" in response_data:
339
+ error_message = response_data["detail"]
340
+ else:
341
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
342
+ except Exception: # pragma: no cover
343
+ error_message = get_error_message(status_code, url, "PATCH") # pragma: no cover
344
+
345
+ raise ToolError(error_message) from e
87
346
 
88
347
 
89
348
  async def call_post(
@@ -102,6 +361,32 @@ async def call_post(
102
361
  timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
103
362
  extensions: RequestExtensions | None = None,
104
363
  ) -> Response:
364
+ """Make a POST request and handle errors appropriately.
365
+
366
+ Args:
367
+ client: The HTTPX AsyncClient to use
368
+ url: The URL to request
369
+ content: Request content
370
+ data: Form data
371
+ files: Files to upload
372
+ json: JSON data
373
+ params: Query parameters
374
+ headers: HTTP headers
375
+ cookies: HTTP cookies
376
+ auth: Authentication
377
+ follow_redirects: Whether to follow redirects
378
+ timeout: Request timeout
379
+ extensions: HTTPX extensions
380
+
381
+ Returns:
382
+ The HTTP response
383
+
384
+ Raises:
385
+ ToolError: If the request fails with an appropriate error message
386
+ """
387
+ logger.debug(f"Calling POST '{url}'")
388
+ error_message = None
389
+
105
390
  try:
106
391
  response = await client.post(
107
392
  url=url,
@@ -117,11 +402,65 @@ async def call_post(
117
402
  timeout=timeout,
118
403
  extensions=extensions,
119
404
  )
120
- response.raise_for_status()
121
- return response
405
+ logger.debug(f"response: {response.json()}")
406
+
407
+ if response.is_success:
408
+ return response
409
+
410
+ # Handle different status codes differently
411
+ status_code = response.status_code
412
+ # get the message if available
413
+ response_data = response.json()
414
+ if isinstance(response_data, dict) and "detail" in response_data:
415
+ error_message = response_data["detail"]
416
+ else:
417
+ error_message = get_error_message(status_code, url, "POST")
418
+
419
+ # Log at appropriate level based on status code
420
+ if 400 <= status_code < 500:
421
+ # Client errors: log as info except for 429 (Too Many Requests)
422
+ if status_code == 429: # pragma: no cover
423
+ logger.warning(f"Rate limit exceeded: POST {url}: {error_message}")
424
+ else: # pragma: no cover
425
+ logger.info(f"Client error: POST {url}: {error_message}")
426
+ else:
427
+ # Server errors: log as error
428
+ logger.error(f"Server error: POST {url}: {error_message}")
429
+
430
+ # Raise a tool error with the friendly message
431
+ response.raise_for_status() # Will always raise since we're in the error case
432
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
433
+
434
+ except HTTPStatusError as e:
435
+ raise ToolError(error_message) from e
436
+
437
+
438
+ async def resolve_entity_id(client: AsyncClient, project_external_id: str, identifier: str) -> str:
439
+ """Resolve a string identifier to an entity external_id using the v2 API.
440
+
441
+ Args:
442
+ client: HTTP client for API calls
443
+ project_external_id: Project external ID (UUID)
444
+ identifier: The identifier to resolve (permalink, title, or path)
445
+
446
+ Returns:
447
+ The resolved entity external_id (UUID)
448
+
449
+ Raises:
450
+ ToolError: If the identifier cannot be resolved
451
+ """
452
+ try:
453
+ response = await call_post(
454
+ client, f"/v2/projects/{project_external_id}/knowledge/resolve", json={"identifier": identifier}
455
+ )
456
+ data = response.json()
457
+ return data["external_id"]
122
458
  except HTTPStatusError as e:
123
- logger.error(f"Error calling POST {url}: {e}")
124
- raise ToolError(f"Error calling tool: {e}") from e
459
+ if e.response.status_code == 404: # pragma: no cover
460
+ raise ToolError(f"Entity not found: '{identifier}'") # pragma: no cover
461
+ raise ToolError(f"Error resolving identifier '{identifier}': {e}") # pragma: no cover
462
+ except Exception as e:
463
+ raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}") # pragma: no cover
125
464
 
126
465
 
127
466
  async def call_delete(
@@ -136,6 +475,28 @@ async def call_delete(
136
475
  timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
137
476
  extensions: RequestExtensions | None = None,
138
477
  ) -> Response:
478
+ """Make a DELETE request and handle errors appropriately.
479
+
480
+ Args:
481
+ client: The HTTPX AsyncClient to use
482
+ url: The URL to request
483
+ params: Query parameters
484
+ headers: HTTP headers
485
+ cookies: HTTP cookies
486
+ auth: Authentication
487
+ follow_redirects: Whether to follow redirects
488
+ timeout: Request timeout
489
+ extensions: HTTPX extensions
490
+
491
+ Returns:
492
+ The HTTP response
493
+
494
+ Raises:
495
+ ToolError: If the request fails with an appropriate error message
496
+ """
497
+ logger.debug(f"Calling DELETE '{url}'")
498
+ error_message = None
499
+
139
500
  try:
140
501
  response = await client.delete(
141
502
  url=url,
@@ -147,8 +508,33 @@ async def call_delete(
147
508
  timeout=timeout,
148
509
  extensions=extensions,
149
510
  )
150
- response.raise_for_status()
151
- return response
511
+
512
+ if response.is_success:
513
+ return response
514
+
515
+ # Handle different status codes differently
516
+ status_code = response.status_code
517
+ # get the message if available
518
+ response_data = response.json()
519
+ if isinstance(response_data, dict) and "detail" in response_data:
520
+ error_message = response_data["detail"] # pragma: no cover
521
+ else:
522
+ error_message = get_error_message(status_code, url, "DELETE")
523
+
524
+ # Log at appropriate level based on status code
525
+ if 400 <= status_code < 500:
526
+ # Client errors: log as info except for 429 (Too Many Requests)
527
+ if status_code == 429: # pragma: no cover
528
+ logger.warning(f"Rate limit exceeded: DELETE {url}: {error_message}")
529
+ else:
530
+ logger.info(f"Client error: DELETE {url}: {error_message}")
531
+ else: # pragma: no cover
532
+ # Server errors: log as error
533
+ logger.error(f"Server error: DELETE {url}: {error_message}")
534
+
535
+ # Raise a tool error with the friendly message
536
+ response.raise_for_status() # Will always raise since we're in the error case
537
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
538
+
152
539
  except HTTPStatusError as e:
153
- logger.error(f"Error calling DELETE {url}: {e}")
154
- raise ToolError(f"Error calling tool: {e}") from e
540
+ raise ToolError(error_message) from e
@@ -0,0 +1,78 @@
1
+ """View note tool for Basic Memory MCP server."""
2
+
3
+ from textwrap import dedent
4
+ from typing import Optional
5
+
6
+ from loguru import logger
7
+ from fastmcp import Context
8
+
9
+ from basic_memory.mcp.server import mcp
10
+ from basic_memory.mcp.tools.read_note import read_note
11
+ from basic_memory.telemetry import track_mcp_tool
12
+
13
+
14
+ @mcp.tool(
15
+ description="View a note as a formatted artifact for better readability.",
16
+ )
17
+ async def view_note(
18
+ identifier: str,
19
+ project: Optional[str] = None,
20
+ page: int = 1,
21
+ page_size: int = 10,
22
+ context: Context | None = None,
23
+ ) -> str:
24
+ """View a markdown note as a formatted artifact.
25
+
26
+ This tool reads a note using the same logic as read_note but instructs Claude
27
+ to display the content as a markdown artifact in the Claude Desktop app.
28
+ Project parameter optional with server resolution.
29
+
30
+ Args:
31
+ identifier: The title or permalink of the note to view
32
+ project: Project name to read from. Optional - server will resolve using hierarchy.
33
+ If unknown, use list_memory_projects() to discover available projects.
34
+ page: Page number for paginated results (default: 1)
35
+ page_size: Number of items per page (default: 10)
36
+ context: Optional FastMCP context for performance caching.
37
+
38
+ Returns:
39
+ Instructions for Claude to create a markdown artifact with the note content.
40
+
41
+ Examples:
42
+ # View a note by title
43
+ view_note("Meeting Notes")
44
+
45
+ # View a note by permalink
46
+ view_note("meetings/weekly-standup")
47
+
48
+ # View with pagination
49
+ view_note("large-document", page=2, page_size=5)
50
+
51
+ # Explicit project specification
52
+ view_note("Meeting Notes", project="my-project")
53
+
54
+ Raises:
55
+ HTTPError: If project doesn't exist or is inaccessible
56
+ SecurityError: If identifier attempts path traversal
57
+ """
58
+ track_mcp_tool("view_note")
59
+ logger.info(f"Viewing note: {identifier} in project: {project}")
60
+
61
+ # Call the existing read_note logic
62
+ content = await read_note.fn(identifier, project, page, page_size, context)
63
+
64
+ # Check if this is an error message (note not found)
65
+ if "# Note Not Found" in content:
66
+ return content # Return error message directly
67
+
68
+ # Return instructions for Claude to create an artifact
69
+ return dedent(f"""
70
+ Note retrieved: "{identifier}"
71
+
72
+ Display this note as a markdown artifact for the user.
73
+
74
+ Content:
75
+ ---
76
+ {content}
77
+ ---
78
+ """).strip()