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.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- 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()
|