basic-memory 0.17.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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,540 @@
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
+
7
+ import typing
8
+ from typing import Optional
9
+
10
+ from httpx import Response, URL, AsyncClient, HTTPStatusError
11
+ from httpx._client import UseClientDefault, USE_CLIENT_DEFAULT
12
+ from httpx._types import (
13
+ RequestContent,
14
+ RequestData,
15
+ RequestFiles,
16
+ QueryParamTypes,
17
+ HeaderTypes,
18
+ CookieTypes,
19
+ AuthTypes,
20
+ TimeoutTypes,
21
+ RequestExtensions,
22
+ )
23
+ from loguru import logger
24
+ from mcp.server.fastmcp.exceptions import ToolError
25
+
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
+
77
+ async def call_get(
78
+ client: AsyncClient,
79
+ url: URL | str,
80
+ *,
81
+ params: QueryParamTypes | None = None,
82
+ headers: HeaderTypes | None = None,
83
+ cookies: CookieTypes | None = None,
84
+ auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT,
85
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
86
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
87
+ extensions: RequestExtensions | None = None,
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
+ """
108
+ logger.debug(f"Calling GET '{url}' params: '{params}'")
109
+ error_message = None
110
+
111
+ try:
112
+ response = await client.get(
113
+ url,
114
+ params=params,
115
+ headers=headers,
116
+ cookies=cookies,
117
+ auth=auth,
118
+ follow_redirects=follow_redirects,
119
+ timeout=timeout,
120
+ extensions=extensions,
121
+ )
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
+
150
+ except HTTPStatusError as e:
151
+ raise ToolError(error_message) from e
152
+
153
+
154
+ async def call_put(
155
+ client: AsyncClient,
156
+ url: URL | str,
157
+ *,
158
+ content: RequestContent | None = None,
159
+ data: RequestData | None = None,
160
+ files: RequestFiles | None = None,
161
+ json: typing.Any | None = None,
162
+ params: QueryParamTypes | None = None,
163
+ headers: HeaderTypes | None = None,
164
+ cookies: CookieTypes | None = None,
165
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
166
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
167
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
168
+ extensions: RequestExtensions | None = None,
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
+
196
+ try:
197
+ response = await client.put(
198
+ url,
199
+ content=content,
200
+ data=data,
201
+ files=files,
202
+ json=json,
203
+ params=params,
204
+ headers=headers,
205
+ cookies=cookies,
206
+ auth=auth,
207
+ follow_redirects=follow_redirects,
208
+ timeout=timeout,
209
+ extensions=extensions,
210
+ )
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
+
240
+ except HTTPStatusError as 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
346
+
347
+
348
+ async def call_post(
349
+ client: AsyncClient,
350
+ url: URL | str,
351
+ *,
352
+ content: RequestContent | None = None,
353
+ data: RequestData | None = None,
354
+ files: RequestFiles | None = None,
355
+ json: typing.Any | None = None,
356
+ params: QueryParamTypes | None = None,
357
+ headers: HeaderTypes | None = None,
358
+ cookies: CookieTypes | None = None,
359
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
360
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
361
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
362
+ extensions: RequestExtensions | None = None,
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
+
390
+ try:
391
+ response = await client.post(
392
+ url=url,
393
+ content=content,
394
+ data=data,
395
+ files=files,
396
+ json=json,
397
+ params=params,
398
+ headers=headers,
399
+ cookies=cookies,
400
+ auth=auth,
401
+ follow_redirects=follow_redirects,
402
+ timeout=timeout,
403
+ extensions=extensions,
404
+ )
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_id: int, identifier: str) -> int:
439
+ """Resolve a string identifier to an entity ID using the v2 API.
440
+
441
+ Args:
442
+ client: HTTP client for API calls
443
+ project_id: Project ID
444
+ identifier: The identifier to resolve (permalink, title, or path)
445
+
446
+ Returns:
447
+ The resolved entity ID
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_id}/knowledge/resolve", json={"identifier": identifier}
455
+ )
456
+ data = response.json()
457
+ return data["entity_id"]
458
+ except HTTPStatusError as e:
459
+ if e.response.status_code == 404:
460
+ raise ToolError(f"Entity not found: '{identifier}'")
461
+ raise ToolError(f"Error resolving identifier '{identifier}': {e}")
462
+ except Exception as e:
463
+ raise ToolError(f"Unexpected error resolving identifier '{identifier}': {e}")
464
+
465
+
466
+ async def call_delete(
467
+ client: AsyncClient,
468
+ url: URL | str,
469
+ *,
470
+ params: QueryParamTypes | None = None,
471
+ headers: HeaderTypes | None = None,
472
+ cookies: CookieTypes | None = None,
473
+ auth: AuthTypes | UseClientDefault = USE_CLIENT_DEFAULT,
474
+ follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT,
475
+ timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT,
476
+ extensions: RequestExtensions | None = None,
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
+
500
+ try:
501
+ response = await client.delete(
502
+ url=url,
503
+ params=params,
504
+ headers=headers,
505
+ cookies=cookies,
506
+ auth=auth,
507
+ follow_redirects=follow_redirects,
508
+ timeout=timeout,
509
+ extensions=extensions,
510
+ )
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
+
539
+ except HTTPStatusError as 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()