basic-memory 0.7.0__py3-none-any.whl → 0.16.1__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 (150) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +27 -3
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  6. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  7. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  8. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  9. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  10. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  11. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +100 -0
  12. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  13. basic_memory/api/app.py +64 -18
  14. basic_memory/api/routers/__init__.py +4 -1
  15. basic_memory/api/routers/directory_router.py +84 -0
  16. basic_memory/api/routers/importer_router.py +152 -0
  17. basic_memory/api/routers/knowledge_router.py +166 -21
  18. basic_memory/api/routers/management_router.py +80 -0
  19. basic_memory/api/routers/memory_router.py +9 -64
  20. basic_memory/api/routers/project_router.py +406 -0
  21. basic_memory/api/routers/prompt_router.py +260 -0
  22. basic_memory/api/routers/resource_router.py +119 -4
  23. basic_memory/api/routers/search_router.py +5 -5
  24. basic_memory/api/routers/utils.py +130 -0
  25. basic_memory/api/template_loader.py +292 -0
  26. basic_memory/cli/app.py +43 -9
  27. basic_memory/cli/auth.py +277 -0
  28. basic_memory/cli/commands/__init__.py +13 -2
  29. basic_memory/cli/commands/cloud/__init__.py +6 -0
  30. basic_memory/cli/commands/cloud/api_client.py +112 -0
  31. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  32. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  33. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  34. basic_memory/cli/commands/cloud/rclone_commands.py +301 -0
  35. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  36. basic_memory/cli/commands/cloud/rclone_installer.py +249 -0
  37. basic_memory/cli/commands/cloud/upload.py +233 -0
  38. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  39. basic_memory/cli/commands/command_utils.py +51 -0
  40. basic_memory/cli/commands/db.py +28 -12
  41. basic_memory/cli/commands/import_chatgpt.py +40 -220
  42. basic_memory/cli/commands/import_claude_conversations.py +41 -168
  43. basic_memory/cli/commands/import_claude_projects.py +46 -157
  44. basic_memory/cli/commands/import_memory_json.py +48 -108
  45. basic_memory/cli/commands/mcp.py +84 -10
  46. basic_memory/cli/commands/project.py +876 -0
  47. basic_memory/cli/commands/status.py +50 -33
  48. basic_memory/cli/commands/tool.py +341 -0
  49. basic_memory/cli/main.py +8 -7
  50. basic_memory/config.py +477 -23
  51. basic_memory/db.py +168 -17
  52. basic_memory/deps.py +251 -25
  53. basic_memory/file_utils.py +113 -58
  54. basic_memory/ignore_utils.py +297 -0
  55. basic_memory/importers/__init__.py +27 -0
  56. basic_memory/importers/base.py +79 -0
  57. basic_memory/importers/chatgpt_importer.py +232 -0
  58. basic_memory/importers/claude_conversations_importer.py +177 -0
  59. basic_memory/importers/claude_projects_importer.py +148 -0
  60. basic_memory/importers/memory_json_importer.py +108 -0
  61. basic_memory/importers/utils.py +58 -0
  62. basic_memory/markdown/entity_parser.py +143 -23
  63. basic_memory/markdown/markdown_processor.py +3 -3
  64. basic_memory/markdown/plugins.py +39 -21
  65. basic_memory/markdown/schemas.py +1 -1
  66. basic_memory/markdown/utils.py +28 -13
  67. basic_memory/mcp/async_client.py +134 -4
  68. basic_memory/mcp/project_context.py +141 -0
  69. basic_memory/mcp/prompts/__init__.py +19 -0
  70. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  71. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  72. basic_memory/mcp/prompts/recent_activity.py +188 -0
  73. basic_memory/mcp/prompts/search.py +57 -0
  74. basic_memory/mcp/prompts/utils.py +162 -0
  75. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  76. basic_memory/mcp/resources/project_info.py +71 -0
  77. basic_memory/mcp/server.py +7 -13
  78. basic_memory/mcp/tools/__init__.py +33 -21
  79. basic_memory/mcp/tools/build_context.py +120 -0
  80. basic_memory/mcp/tools/canvas.py +130 -0
  81. basic_memory/mcp/tools/chatgpt_tools.py +187 -0
  82. basic_memory/mcp/tools/delete_note.py +225 -0
  83. basic_memory/mcp/tools/edit_note.py +320 -0
  84. basic_memory/mcp/tools/list_directory.py +167 -0
  85. basic_memory/mcp/tools/move_note.py +545 -0
  86. basic_memory/mcp/tools/project_management.py +200 -0
  87. basic_memory/mcp/tools/read_content.py +271 -0
  88. basic_memory/mcp/tools/read_note.py +255 -0
  89. basic_memory/mcp/tools/recent_activity.py +534 -0
  90. basic_memory/mcp/tools/search.py +369 -23
  91. basic_memory/mcp/tools/utils.py +374 -16
  92. basic_memory/mcp/tools/view_note.py +77 -0
  93. basic_memory/mcp/tools/write_note.py +207 -0
  94. basic_memory/models/__init__.py +3 -2
  95. basic_memory/models/knowledge.py +67 -15
  96. basic_memory/models/project.py +87 -0
  97. basic_memory/models/search.py +10 -6
  98. basic_memory/repository/__init__.py +2 -0
  99. basic_memory/repository/entity_repository.py +229 -7
  100. basic_memory/repository/observation_repository.py +35 -3
  101. basic_memory/repository/project_info_repository.py +10 -0
  102. basic_memory/repository/project_repository.py +103 -0
  103. basic_memory/repository/relation_repository.py +21 -2
  104. basic_memory/repository/repository.py +147 -29
  105. basic_memory/repository/search_repository.py +411 -62
  106. basic_memory/schemas/__init__.py +22 -9
  107. basic_memory/schemas/base.py +97 -8
  108. basic_memory/schemas/cloud.py +50 -0
  109. basic_memory/schemas/directory.py +30 -0
  110. basic_memory/schemas/importer.py +35 -0
  111. basic_memory/schemas/memory.py +187 -25
  112. basic_memory/schemas/project_info.py +211 -0
  113. basic_memory/schemas/prompt.py +90 -0
  114. basic_memory/schemas/request.py +56 -2
  115. basic_memory/schemas/response.py +1 -1
  116. basic_memory/schemas/search.py +31 -35
  117. basic_memory/schemas/sync_report.py +72 -0
  118. basic_memory/services/__init__.py +2 -1
  119. basic_memory/services/context_service.py +241 -104
  120. basic_memory/services/directory_service.py +295 -0
  121. basic_memory/services/entity_service.py +590 -60
  122. basic_memory/services/exceptions.py +21 -0
  123. basic_memory/services/file_service.py +284 -30
  124. basic_memory/services/initialization.py +191 -0
  125. basic_memory/services/link_resolver.py +49 -56
  126. basic_memory/services/project_service.py +863 -0
  127. basic_memory/services/search_service.py +168 -32
  128. basic_memory/sync/__init__.py +3 -2
  129. basic_memory/sync/background_sync.py +26 -0
  130. basic_memory/sync/sync_service.py +1180 -109
  131. basic_memory/sync/watch_service.py +412 -135
  132. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  133. basic_memory/templates/prompts/search.hbs +101 -0
  134. basic_memory/utils.py +383 -51
  135. basic_memory-0.16.1.dist-info/METADATA +493 -0
  136. basic_memory-0.16.1.dist-info/RECORD +148 -0
  137. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/entry_points.txt +1 -0
  138. basic_memory/alembic/README +0 -1
  139. basic_memory/cli/commands/sync.py +0 -206
  140. basic_memory/cli/commands/tools.py +0 -157
  141. basic_memory/mcp/tools/knowledge.py +0 -68
  142. basic_memory/mcp/tools/memory.py +0 -170
  143. basic_memory/mcp/tools/notes.py +0 -202
  144. basic_memory/schemas/discovery.py +0 -28
  145. basic_memory/sync/file_change_scanner.py +0 -158
  146. basic_memory/sync/utils.py +0 -31
  147. basic_memory-0.7.0.dist-info/METADATA +0 -378
  148. basic_memory-0.7.0.dist-info/RECORD +0 -82
  149. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.dist-info}/WHEEL +0 -0
  150. {basic_memory-0.7.0.dist-info → basic_memory-0.16.1.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,37 @@ 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
+
122
434
  except HTTPStatusError as e:
123
- logger.error(f"Error calling POST {url}: {e}")
124
- raise ToolError(f"Error calling tool: {e}") from e
435
+ raise ToolError(error_message) from e
125
436
 
126
437
 
127
438
  async def call_delete(
@@ -136,6 +447,28 @@ async def call_delete(
136
447
  timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
137
448
  extensions: RequestExtensions | None = None,
138
449
  ) -> Response:
450
+ """Make a DELETE request and handle errors appropriately.
451
+
452
+ Args:
453
+ client: The HTTPX AsyncClient to use
454
+ url: The URL to request
455
+ params: Query parameters
456
+ headers: HTTP headers
457
+ cookies: HTTP cookies
458
+ auth: Authentication
459
+ follow_redirects: Whether to follow redirects
460
+ timeout: Request timeout
461
+ extensions: HTTPX extensions
462
+
463
+ Returns:
464
+ The HTTP response
465
+
466
+ Raises:
467
+ ToolError: If the request fails with an appropriate error message
468
+ """
469
+ logger.debug(f"Calling DELETE '{url}'")
470
+ error_message = None
471
+
139
472
  try:
140
473
  response = await client.delete(
141
474
  url=url,
@@ -147,8 +480,33 @@ async def call_delete(
147
480
  timeout=timeout,
148
481
  extensions=extensions,
149
482
  )
150
- response.raise_for_status()
151
- return response
483
+
484
+ if response.is_success:
485
+ return response
486
+
487
+ # Handle different status codes differently
488
+ status_code = response.status_code
489
+ # get the message if available
490
+ response_data = response.json()
491
+ if isinstance(response_data, dict) and "detail" in response_data:
492
+ error_message = response_data["detail"] # pragma: no cover
493
+ else:
494
+ error_message = get_error_message(status_code, url, "DELETE")
495
+
496
+ # Log at appropriate level based on status code
497
+ if 400 <= status_code < 500:
498
+ # Client errors: log as info except for 429 (Too Many Requests)
499
+ if status_code == 429: # pragma: no cover
500
+ logger.warning(f"Rate limit exceeded: DELETE {url}: {error_message}")
501
+ else:
502
+ logger.info(f"Client error: DELETE {url}: {error_message}")
503
+ else: # pragma: no cover
504
+ # Server errors: log as error
505
+ logger.error(f"Server error: DELETE {url}: {error_message}")
506
+
507
+ # Raise a tool error with the friendly message
508
+ response.raise_for_status() # Will always raise since we're in the error case
509
+ return response # This line will never execute, but it satisfies the type checker # pragma: no cover
510
+
152
511
  except HTTPStatusError as e:
153
- logger.error(f"Error calling DELETE {url}: {e}")
154
- raise ToolError(f"Error calling tool: {e}") from e
512
+ raise ToolError(error_message) from e
@@ -0,0 +1,77 @@
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
+
12
+
13
+ @mcp.tool(
14
+ description="View a note as a formatted artifact for better readability.",
15
+ )
16
+ async def view_note(
17
+ identifier: str,
18
+ project: Optional[str] = None,
19
+ page: int = 1,
20
+ page_size: int = 10,
21
+ context: Context | None = None,
22
+ ) -> str:
23
+ """View a markdown note as a formatted artifact.
24
+
25
+ This tool reads a note using the same logic as read_note but instructs Claude
26
+ to display the content as a markdown artifact in the Claude Desktop app.
27
+ Project parameter optional with server resolution.
28
+
29
+ Args:
30
+ identifier: The title or permalink of the note to view
31
+ project: Project name to read from. Optional - server will resolve using hierarchy.
32
+ If unknown, use list_memory_projects() to discover available projects.
33
+ page: Page number for paginated results (default: 1)
34
+ page_size: Number of items per page (default: 10)
35
+ context: Optional FastMCP context for performance caching.
36
+
37
+ Returns:
38
+ Instructions for Claude to create a markdown artifact with the note content.
39
+
40
+ Examples:
41
+ # View a note by title
42
+ view_note("Meeting Notes")
43
+
44
+ # View a note by permalink
45
+ view_note("meetings/weekly-standup")
46
+
47
+ # View with pagination
48
+ view_note("large-document", page=2, page_size=5)
49
+
50
+ # Explicit project specification
51
+ view_note("Meeting Notes", project="my-project")
52
+
53
+ Raises:
54
+ HTTPError: If project doesn't exist or is inaccessible
55
+ SecurityError: If identifier attempts path traversal
56
+ """
57
+
58
+ logger.info(f"Viewing note: {identifier} in project: {project}")
59
+
60
+ # Call the existing read_note logic
61
+ content = await read_note.fn(identifier, project, page, page_size, context)
62
+
63
+ # Check if this is an error message (note not found)
64
+ if "# Note Not Found" in content:
65
+ return content # Return error message directly
66
+
67
+ # Return instructions for Claude to create an artifact
68
+ return dedent(f"""
69
+ Note retrieved: "{identifier}"
70
+
71
+ Display this note as a markdown artifact for the user.
72
+
73
+ Content:
74
+ ---
75
+ {content}
76
+ ---
77
+ """).strip()