basic-memory 0.12.2__py3-none-any.whl → 0.13.0__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 (117) hide show
  1. basic_memory/__init__.py +2 -1
  2. basic_memory/alembic/env.py +1 -1
  3. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
  4. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
  5. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
  6. basic_memory/api/app.py +43 -13
  7. basic_memory/api/routers/__init__.py +4 -2
  8. basic_memory/api/routers/directory_router.py +63 -0
  9. basic_memory/api/routers/importer_router.py +152 -0
  10. basic_memory/api/routers/knowledge_router.py +139 -37
  11. basic_memory/api/routers/management_router.py +78 -0
  12. basic_memory/api/routers/memory_router.py +6 -62
  13. basic_memory/api/routers/project_router.py +234 -0
  14. basic_memory/api/routers/prompt_router.py +260 -0
  15. basic_memory/api/routers/search_router.py +3 -21
  16. basic_memory/api/routers/utils.py +130 -0
  17. basic_memory/api/template_loader.py +292 -0
  18. basic_memory/cli/app.py +20 -21
  19. basic_memory/cli/commands/__init__.py +2 -1
  20. basic_memory/cli/commands/auth.py +136 -0
  21. basic_memory/cli/commands/db.py +3 -3
  22. basic_memory/cli/commands/import_chatgpt.py +31 -207
  23. basic_memory/cli/commands/import_claude_conversations.py +16 -142
  24. basic_memory/cli/commands/import_claude_projects.py +33 -143
  25. basic_memory/cli/commands/import_memory_json.py +26 -83
  26. basic_memory/cli/commands/mcp.py +71 -18
  27. basic_memory/cli/commands/project.py +102 -70
  28. basic_memory/cli/commands/status.py +19 -9
  29. basic_memory/cli/commands/sync.py +44 -58
  30. basic_memory/cli/commands/tool.py +6 -6
  31. basic_memory/cli/main.py +1 -5
  32. basic_memory/config.py +143 -87
  33. basic_memory/db.py +6 -4
  34. basic_memory/deps.py +227 -30
  35. basic_memory/importers/__init__.py +27 -0
  36. basic_memory/importers/base.py +79 -0
  37. basic_memory/importers/chatgpt_importer.py +222 -0
  38. basic_memory/importers/claude_conversations_importer.py +172 -0
  39. basic_memory/importers/claude_projects_importer.py +148 -0
  40. basic_memory/importers/memory_json_importer.py +93 -0
  41. basic_memory/importers/utils.py +58 -0
  42. basic_memory/markdown/entity_parser.py +5 -2
  43. basic_memory/mcp/auth_provider.py +270 -0
  44. basic_memory/mcp/external_auth_provider.py +321 -0
  45. basic_memory/mcp/project_session.py +103 -0
  46. basic_memory/mcp/prompts/__init__.py +2 -0
  47. basic_memory/mcp/prompts/continue_conversation.py +18 -68
  48. basic_memory/mcp/prompts/recent_activity.py +20 -4
  49. basic_memory/mcp/prompts/search.py +14 -140
  50. basic_memory/mcp/prompts/sync_status.py +116 -0
  51. basic_memory/mcp/prompts/utils.py +3 -3
  52. basic_memory/mcp/{tools → resources}/project_info.py +6 -2
  53. basic_memory/mcp/server.py +86 -13
  54. basic_memory/mcp/supabase_auth_provider.py +463 -0
  55. basic_memory/mcp/tools/__init__.py +24 -0
  56. basic_memory/mcp/tools/build_context.py +43 -8
  57. basic_memory/mcp/tools/canvas.py +17 -3
  58. basic_memory/mcp/tools/delete_note.py +168 -5
  59. basic_memory/mcp/tools/edit_note.py +303 -0
  60. basic_memory/mcp/tools/list_directory.py +154 -0
  61. basic_memory/mcp/tools/move_note.py +299 -0
  62. basic_memory/mcp/tools/project_management.py +332 -0
  63. basic_memory/mcp/tools/read_content.py +15 -6
  64. basic_memory/mcp/tools/read_note.py +28 -9
  65. basic_memory/mcp/tools/recent_activity.py +47 -16
  66. basic_memory/mcp/tools/search.py +189 -8
  67. basic_memory/mcp/tools/sync_status.py +254 -0
  68. basic_memory/mcp/tools/utils.py +184 -12
  69. basic_memory/mcp/tools/view_note.py +66 -0
  70. basic_memory/mcp/tools/write_note.py +24 -17
  71. basic_memory/models/__init__.py +3 -2
  72. basic_memory/models/knowledge.py +16 -4
  73. basic_memory/models/project.py +78 -0
  74. basic_memory/models/search.py +8 -5
  75. basic_memory/repository/__init__.py +2 -0
  76. basic_memory/repository/entity_repository.py +8 -3
  77. basic_memory/repository/observation_repository.py +35 -3
  78. basic_memory/repository/project_info_repository.py +3 -2
  79. basic_memory/repository/project_repository.py +85 -0
  80. basic_memory/repository/relation_repository.py +8 -2
  81. basic_memory/repository/repository.py +107 -15
  82. basic_memory/repository/search_repository.py +192 -54
  83. basic_memory/schemas/__init__.py +6 -0
  84. basic_memory/schemas/base.py +33 -5
  85. basic_memory/schemas/directory.py +30 -0
  86. basic_memory/schemas/importer.py +34 -0
  87. basic_memory/schemas/memory.py +84 -13
  88. basic_memory/schemas/project_info.py +112 -2
  89. basic_memory/schemas/prompt.py +90 -0
  90. basic_memory/schemas/request.py +56 -2
  91. basic_memory/schemas/search.py +1 -1
  92. basic_memory/services/__init__.py +2 -1
  93. basic_memory/services/context_service.py +208 -95
  94. basic_memory/services/directory_service.py +167 -0
  95. basic_memory/services/entity_service.py +399 -6
  96. basic_memory/services/exceptions.py +6 -0
  97. basic_memory/services/file_service.py +14 -15
  98. basic_memory/services/initialization.py +170 -66
  99. basic_memory/services/link_resolver.py +35 -12
  100. basic_memory/services/migration_service.py +168 -0
  101. basic_memory/services/project_service.py +671 -0
  102. basic_memory/services/search_service.py +77 -2
  103. basic_memory/services/sync_status_service.py +181 -0
  104. basic_memory/sync/background_sync.py +25 -0
  105. basic_memory/sync/sync_service.py +102 -21
  106. basic_memory/sync/watch_service.py +63 -39
  107. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  108. basic_memory/templates/prompts/search.hbs +101 -0
  109. basic_memory/utils.py +67 -17
  110. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/METADATA +26 -4
  111. basic_memory-0.13.0.dist-info/RECORD +138 -0
  112. basic_memory/api/routers/project_info_router.py +0 -274
  113. basic_memory/mcp/main.py +0 -24
  114. basic_memory-0.12.2.dist-info/RECORD +0 -100
  115. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
  116. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
  117. {basic_memory-0.12.2.dist-info → basic_memory-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,463 @@
1
+ """Supabase OAuth provider for Basic Memory MCP server."""
2
+
3
+ import os
4
+ import secrets
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional, Dict, Any
8
+
9
+ import httpx
10
+ import jwt
11
+ from loguru import logger
12
+ from mcp.server.auth.provider import (
13
+ OAuthAuthorizationServerProvider,
14
+ AuthorizationParams,
15
+ AuthorizationCode,
16
+ RefreshToken,
17
+ AccessToken,
18
+ TokenError,
19
+ AuthorizeError,
20
+ )
21
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
22
+
23
+
24
+ @dataclass
25
+ class SupabaseAuthorizationCode(AuthorizationCode):
26
+ """Authorization code with Supabase metadata."""
27
+
28
+ user_id: Optional[str] = None
29
+ email: Optional[str] = None
30
+
31
+
32
+ @dataclass
33
+ class SupabaseRefreshToken(RefreshToken):
34
+ """Refresh token with Supabase metadata."""
35
+
36
+ supabase_refresh_token: Optional[str] = None
37
+ user_id: Optional[str] = None
38
+
39
+
40
+ @dataclass
41
+ class SupabaseAccessToken(AccessToken):
42
+ """Access token with Supabase metadata."""
43
+
44
+ supabase_access_token: Optional[str] = None
45
+ user_id: Optional[str] = None
46
+ email: Optional[str] = None
47
+
48
+
49
+ class SupabaseOAuthProvider(
50
+ OAuthAuthorizationServerProvider[
51
+ SupabaseAuthorizationCode, SupabaseRefreshToken, SupabaseAccessToken
52
+ ]
53
+ ):
54
+ """OAuth provider that integrates with Supabase Auth.
55
+
56
+ This provider uses Supabase as the authentication backend while
57
+ maintaining compatibility with MCP's OAuth requirements.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ supabase_url: str,
63
+ supabase_anon_key: str,
64
+ supabase_service_key: Optional[str] = None,
65
+ issuer_url: str = "http://localhost:8000",
66
+ ):
67
+ self.supabase_url = supabase_url.rstrip("/")
68
+ self.supabase_anon_key = supabase_anon_key
69
+ self.supabase_service_key = supabase_service_key or supabase_anon_key
70
+ self.issuer_url = issuer_url
71
+
72
+ # HTTP client for Supabase API calls
73
+ self.http_client = httpx.AsyncClient()
74
+
75
+ # Temporary storage for auth flows (in production, use Supabase DB)
76
+ self.pending_auth_codes: Dict[str, SupabaseAuthorizationCode] = {}
77
+ self.mcp_to_supabase_tokens: Dict[str, Dict[str, Any]] = {}
78
+
79
+ async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
80
+ """Get a client from Supabase.
81
+
82
+ In production, this would query a clients table in Supabase.
83
+ """
84
+ # For now, we'll validate against a configured list of allowed clients
85
+ # In production, query Supabase DB for client info
86
+ allowed_clients = os.getenv("SUPABASE_ALLOWED_CLIENTS", "").split(",")
87
+
88
+ if client_id in allowed_clients:
89
+ return OAuthClientInformationFull(
90
+ client_id=client_id,
91
+ client_secret="", # Supabase handles secrets
92
+ redirect_uris=[], # Supabase handles redirect URIs
93
+ )
94
+
95
+ return None
96
+
97
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
98
+ """Register a new OAuth client in Supabase.
99
+
100
+ In production, this would insert into a clients table.
101
+ """
102
+ # For development, we just log the registration
103
+ logger.info(f"Would register client {client_info.client_id} in Supabase")
104
+
105
+ # In production:
106
+ # await self.supabase.table('oauth_clients').insert({
107
+ # 'client_id': client_info.client_id,
108
+ # 'client_secret': client_info.client_secret,
109
+ # 'metadata': client_info.client_metadata,
110
+ # }).execute()
111
+
112
+ async def authorize(
113
+ self, client: OAuthClientInformationFull, params: AuthorizationParams
114
+ ) -> str:
115
+ """Create authorization URL redirecting to Supabase Auth.
116
+
117
+ This initiates the OAuth flow with Supabase as the identity provider.
118
+ """
119
+ # Generate state for this auth request
120
+ state = secrets.token_urlsafe(32)
121
+
122
+ # Store the authorization request
123
+ self.pending_auth_codes[state] = SupabaseAuthorizationCode(
124
+ code=state,
125
+ scopes=params.scopes or [],
126
+ expires_at=(datetime.utcnow() + timedelta(minutes=10)).timestamp(),
127
+ client_id=client.client_id,
128
+ code_challenge=params.code_challenge,
129
+ redirect_uri=params.redirect_uri,
130
+ redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
131
+ )
132
+
133
+ # Build Supabase auth URL
134
+ auth_params = {
135
+ "redirect_to": f"{self.issuer_url}/auth/callback",
136
+ "scopes": " ".join(params.scopes or ["openid", "email"]),
137
+ "state": state,
138
+ }
139
+
140
+ # Use Supabase's OAuth endpoint
141
+ auth_url = f"{self.supabase_url}/auth/v1/authorize"
142
+ query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
143
+
144
+ return f"{auth_url}?{query_string}"
145
+
146
+ async def handle_supabase_callback(self, code: str, state: str) -> str:
147
+ """Handle callback from Supabase after user authentication."""
148
+ # Get the original auth request
149
+ auth_request = self.pending_auth_codes.get(state)
150
+ if not auth_request:
151
+ raise AuthorizeError(
152
+ error="invalid_request",
153
+ error_description="Invalid state parameter",
154
+ )
155
+
156
+ # Exchange code with Supabase for tokens
157
+ token_response = await self.http_client.post(
158
+ f"{self.supabase_url}/auth/v1/token",
159
+ json={
160
+ "grant_type": "authorization_code",
161
+ "code": code,
162
+ "redirect_uri": f"{self.issuer_url}/auth/callback",
163
+ },
164
+ headers={
165
+ "apikey": self.supabase_anon_key,
166
+ "Authorization": f"Bearer {self.supabase_anon_key}",
167
+ },
168
+ )
169
+
170
+ if not token_response.is_success:
171
+ raise AuthorizeError(
172
+ error="server_error",
173
+ error_description="Failed to exchange code with Supabase",
174
+ )
175
+
176
+ supabase_tokens = token_response.json()
177
+
178
+ # Get user info from Supabase
179
+ user_response = await self.http_client.get(
180
+ f"{self.supabase_url}/auth/v1/user",
181
+ headers={
182
+ "apikey": self.supabase_anon_key,
183
+ "Authorization": f"Bearer {supabase_tokens['access_token']}",
184
+ },
185
+ )
186
+
187
+ user_data = user_response.json() if user_response.is_success else {}
188
+
189
+ # Generate MCP authorization code
190
+ mcp_code = secrets.token_urlsafe(32)
191
+
192
+ # Update auth request with user info
193
+ auth_request.code = mcp_code
194
+ auth_request.user_id = user_data.get("id")
195
+ auth_request.email = user_data.get("email")
196
+
197
+ # Store mapping
198
+ self.pending_auth_codes[mcp_code] = auth_request
199
+ self.mcp_to_supabase_tokens[mcp_code] = {
200
+ "supabase_tokens": supabase_tokens,
201
+ "user": user_data,
202
+ }
203
+
204
+ # Clean up old state
205
+ del self.pending_auth_codes[state]
206
+
207
+ # Redirect back to client
208
+ redirect_uri = str(auth_request.redirect_uri)
209
+ separator = "&" if "?" in redirect_uri else "?"
210
+
211
+ return f"{redirect_uri}{separator}code={mcp_code}&state={state}"
212
+
213
+ async def load_authorization_code(
214
+ self, client: OAuthClientInformationFull, authorization_code: str
215
+ ) -> Optional[SupabaseAuthorizationCode]:
216
+ """Load an authorization code."""
217
+ code = self.pending_auth_codes.get(authorization_code)
218
+
219
+ if code and code.client_id == client.client_id:
220
+ # Check expiration
221
+ if datetime.utcnow().timestamp() > code.expires_at:
222
+ del self.pending_auth_codes[authorization_code]
223
+ return None
224
+ return code
225
+
226
+ return None
227
+
228
+ async def exchange_authorization_code(
229
+ self, client: OAuthClientInformationFull, authorization_code: SupabaseAuthorizationCode
230
+ ) -> OAuthToken:
231
+ """Exchange authorization code for tokens."""
232
+ # Get stored Supabase tokens
233
+ token_data = self.mcp_to_supabase_tokens.get(authorization_code.code)
234
+ if not token_data:
235
+ raise TokenError(error="invalid_grant", error_description="Invalid authorization code")
236
+
237
+ supabase_tokens = token_data["supabase_tokens"]
238
+ user = token_data["user"]
239
+
240
+ # Generate MCP tokens that wrap Supabase tokens
241
+ access_token = self._generate_mcp_token(
242
+ client_id=client.client_id,
243
+ user_id=user.get("id", ""),
244
+ email=user.get("email", ""),
245
+ scopes=authorization_code.scopes,
246
+ supabase_access_token=supabase_tokens["access_token"],
247
+ )
248
+
249
+ refresh_token = secrets.token_urlsafe(32)
250
+
251
+ # Store the token mapping
252
+ self.mcp_to_supabase_tokens[access_token] = {
253
+ "client_id": client.client_id,
254
+ "user_id": user.get("id"),
255
+ "email": user.get("email"),
256
+ "supabase_access_token": supabase_tokens["access_token"],
257
+ "supabase_refresh_token": supabase_tokens["refresh_token"],
258
+ "scopes": authorization_code.scopes,
259
+ }
260
+
261
+ # Store refresh token mapping
262
+ self.mcp_to_supabase_tokens[refresh_token] = {
263
+ "client_id": client.client_id,
264
+ "user_id": user.get("id"),
265
+ "supabase_refresh_token": supabase_tokens["refresh_token"],
266
+ "scopes": authorization_code.scopes,
267
+ }
268
+
269
+ # Clean up authorization code
270
+ del self.pending_auth_codes[authorization_code.code]
271
+ del self.mcp_to_supabase_tokens[authorization_code.code]
272
+
273
+ return OAuthToken(
274
+ access_token=access_token,
275
+ token_type="bearer",
276
+ expires_in=supabase_tokens.get("expires_in", 3600),
277
+ refresh_token=refresh_token,
278
+ scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
279
+ )
280
+
281
+ async def load_refresh_token(
282
+ self, client: OAuthClientInformationFull, refresh_token: str
283
+ ) -> Optional[SupabaseRefreshToken]:
284
+ """Load a refresh token."""
285
+ token_data = self.mcp_to_supabase_tokens.get(refresh_token)
286
+
287
+ if token_data and token_data["client_id"] == client.client_id:
288
+ return SupabaseRefreshToken(
289
+ token=refresh_token,
290
+ client_id=client.client_id,
291
+ scopes=token_data["scopes"],
292
+ supabase_refresh_token=token_data["supabase_refresh_token"],
293
+ user_id=token_data.get("user_id"),
294
+ )
295
+
296
+ return None
297
+
298
+ async def exchange_refresh_token(
299
+ self,
300
+ client: OAuthClientInformationFull,
301
+ refresh_token: SupabaseRefreshToken,
302
+ scopes: list[str],
303
+ ) -> OAuthToken:
304
+ """Exchange refresh token for new tokens using Supabase."""
305
+ # Refresh with Supabase
306
+ token_response = await self.http_client.post(
307
+ f"{self.supabase_url}/auth/v1/token",
308
+ json={
309
+ "grant_type": "refresh_token",
310
+ "refresh_token": refresh_token.supabase_refresh_token,
311
+ },
312
+ headers={
313
+ "apikey": self.supabase_anon_key,
314
+ "Authorization": f"Bearer {self.supabase_anon_key}",
315
+ },
316
+ )
317
+
318
+ if not token_response.is_success:
319
+ raise TokenError(
320
+ error="invalid_grant",
321
+ error_description="Failed to refresh with Supabase",
322
+ )
323
+
324
+ supabase_tokens = token_response.json()
325
+
326
+ # Get updated user info
327
+ user_response = await self.http_client.get(
328
+ f"{self.supabase_url}/auth/v1/user",
329
+ headers={
330
+ "apikey": self.supabase_anon_key,
331
+ "Authorization": f"Bearer {supabase_tokens['access_token']}",
332
+ },
333
+ )
334
+
335
+ user_data = user_response.json() if user_response.is_success else {}
336
+
337
+ # Generate new MCP tokens
338
+ new_access_token = self._generate_mcp_token(
339
+ client_id=client.client_id,
340
+ user_id=user_data.get("id", ""),
341
+ email=user_data.get("email", ""),
342
+ scopes=scopes or refresh_token.scopes,
343
+ supabase_access_token=supabase_tokens["access_token"],
344
+ )
345
+
346
+ new_refresh_token = secrets.token_urlsafe(32)
347
+
348
+ # Update token mappings
349
+ self.mcp_to_supabase_tokens[new_access_token] = {
350
+ "client_id": client.client_id,
351
+ "user_id": user_data.get("id"),
352
+ "email": user_data.get("email"),
353
+ "supabase_access_token": supabase_tokens["access_token"],
354
+ "supabase_refresh_token": supabase_tokens["refresh_token"],
355
+ "scopes": scopes or refresh_token.scopes,
356
+ }
357
+
358
+ self.mcp_to_supabase_tokens[new_refresh_token] = {
359
+ "client_id": client.client_id,
360
+ "user_id": user_data.get("id"),
361
+ "supabase_refresh_token": supabase_tokens["refresh_token"],
362
+ "scopes": scopes or refresh_token.scopes,
363
+ }
364
+
365
+ # Clean up old tokens
366
+ del self.mcp_to_supabase_tokens[refresh_token.token]
367
+
368
+ return OAuthToken(
369
+ access_token=new_access_token,
370
+ token_type="bearer",
371
+ expires_in=supabase_tokens.get("expires_in", 3600),
372
+ refresh_token=new_refresh_token,
373
+ scope=" ".join(scopes or refresh_token.scopes),
374
+ )
375
+
376
+ async def load_access_token(self, token: str) -> Optional[SupabaseAccessToken]:
377
+ """Load and validate an access token."""
378
+ # First check our mapping
379
+ token_data = self.mcp_to_supabase_tokens.get(token)
380
+ if token_data:
381
+ return SupabaseAccessToken(
382
+ token=token,
383
+ client_id=token_data["client_id"],
384
+ scopes=token_data["scopes"],
385
+ supabase_access_token=token_data.get("supabase_access_token"),
386
+ user_id=token_data.get("user_id"),
387
+ email=token_data.get("email"),
388
+ )
389
+
390
+ # Try to decode as JWT
391
+ try:
392
+ # Verify with Supabase's JWT secret
393
+ payload = jwt.decode(
394
+ token,
395
+ os.getenv("SUPABASE_JWT_SECRET", ""),
396
+ algorithms=["HS256"],
397
+ audience="authenticated",
398
+ )
399
+
400
+ return SupabaseAccessToken(
401
+ token=token,
402
+ client_id=payload.get("client_id", ""),
403
+ scopes=payload.get("scopes", []),
404
+ user_id=payload.get("sub"),
405
+ email=payload.get("email"),
406
+ )
407
+ except jwt.InvalidTokenError:
408
+ pass
409
+
410
+ # Validate with Supabase
411
+ user_response = await self.http_client.get(
412
+ f"{self.supabase_url}/auth/v1/user",
413
+ headers={
414
+ "apikey": self.supabase_anon_key,
415
+ "Authorization": f"Bearer {token}",
416
+ },
417
+ )
418
+
419
+ if user_response.is_success:
420
+ user_data = user_response.json()
421
+ return SupabaseAccessToken(
422
+ token=token,
423
+ client_id="", # Unknown client for direct Supabase tokens
424
+ scopes=[],
425
+ supabase_access_token=token,
426
+ user_id=user_data.get("id"),
427
+ email=user_data.get("email"),
428
+ )
429
+
430
+ return None
431
+
432
+ async def revoke_token(self, token: SupabaseAccessToken | SupabaseRefreshToken) -> None:
433
+ """Revoke a token."""
434
+ # Remove from our mapping
435
+ self.mcp_to_supabase_tokens.pop(token.token, None)
436
+
437
+ # In production, also revoke in Supabase:
438
+ # await self.supabase.auth.admin.sign_out(token.user_id)
439
+
440
+ def _generate_mcp_token(
441
+ self,
442
+ client_id: str,
443
+ user_id: str,
444
+ email: str,
445
+ scopes: list[str],
446
+ supabase_access_token: str,
447
+ ) -> str:
448
+ """Generate an MCP token that wraps Supabase authentication."""
449
+ payload = {
450
+ "iss": self.issuer_url,
451
+ "sub": user_id,
452
+ "client_id": client_id,
453
+ "email": email,
454
+ "scopes": scopes,
455
+ "supabase_token": supabase_access_token[:10] + "...", # Reference only
456
+ "exp": datetime.utcnow() + timedelta(hours=1),
457
+ "iat": datetime.utcnow(),
458
+ }
459
+
460
+ # Use Supabase JWT secret if available
461
+ secret = os.getenv("SUPABASE_JWT_SECRET", secrets.token_urlsafe(32))
462
+
463
+ return jwt.encode(payload, secret, algorithm="HS256")
@@ -11,17 +11,41 @@ from basic_memory.mcp.tools.read_content import read_content
11
11
  from basic_memory.mcp.tools.build_context import build_context
12
12
  from basic_memory.mcp.tools.recent_activity import recent_activity
13
13
  from basic_memory.mcp.tools.read_note import read_note
14
+ from basic_memory.mcp.tools.view_note import view_note
14
15
  from basic_memory.mcp.tools.write_note import write_note
15
16
  from basic_memory.mcp.tools.search import search_notes
16
17
  from basic_memory.mcp.tools.canvas import canvas
18
+ from basic_memory.mcp.tools.list_directory import list_directory
19
+ from basic_memory.mcp.tools.edit_note import edit_note
20
+ from basic_memory.mcp.tools.move_note import move_note
21
+ from basic_memory.mcp.tools.sync_status import sync_status
22
+ from basic_memory.mcp.tools.project_management import (
23
+ list_projects,
24
+ switch_project,
25
+ get_current_project,
26
+ set_default_project,
27
+ create_project,
28
+ delete_project,
29
+ )
17
30
 
18
31
  __all__ = [
19
32
  "build_context",
20
33
  "canvas",
34
+ "create_project",
21
35
  "delete_note",
36
+ "delete_project",
37
+ "edit_note",
38
+ "get_current_project",
39
+ "list_directory",
40
+ "list_projects",
41
+ "move_note",
22
42
  "read_content",
23
43
  "read_note",
24
44
  "recent_activity",
25
45
  "search_notes",
46
+ "set_default_project",
47
+ "switch_project",
48
+ "sync_status",
49
+ "view_note",
26
50
  "write_note",
27
51
  ]
@@ -7,12 +7,12 @@ from loguru import logger
7
7
  from basic_memory.mcp.async_client import client
8
8
  from basic_memory.mcp.server import mcp
9
9
  from basic_memory.mcp.tools.utils import call_get
10
+ from basic_memory.mcp.project_session import get_active_project
10
11
  from basic_memory.schemas.base import TimeFrame
11
12
  from basic_memory.schemas.memory import (
12
13
  GraphContext,
13
14
  MemoryUrl,
14
15
  memory_url_path,
15
- normalize_memory_url,
16
16
  )
17
17
 
18
18
 
@@ -20,12 +20,17 @@ from basic_memory.schemas.memory import (
20
20
  description="""Build context from a memory:// URI to continue conversations naturally.
21
21
 
22
22
  Use this to follow up on previous discussions or explore related topics.
23
+
24
+ Memory URL Format:
25
+ - Use paths like "folder/note" or "memory://folder/note"
26
+ - Pattern matching: "folder/*" matches all notes in folder
27
+ - Valid characters: letters, numbers, hyphens, underscores, forward slashes
28
+ - Avoid: double slashes (//), angle brackets (<>), quotes, pipes (|)
29
+ - Examples: "specs/search", "projects/basic-memory", "notes/*"
30
+
23
31
  Timeframes support natural language like:
24
- - "2 days ago"
25
- - "last week"
26
- - "today"
27
- - "3 months ago"
28
- Or standard formats like "7d", "24h"
32
+ - "2 days ago", "last week", "today", "3 months ago"
33
+ - Or standard formats like "7d", "24h"
29
34
  """,
30
35
  )
31
36
  async def build_context(
@@ -35,6 +40,7 @@ async def build_context(
35
40
  page: int = 1,
36
41
  page_size: int = 10,
37
42
  max_related: int = 10,
43
+ project: Optional[str] = None,
38
44
  ) -> GraphContext:
39
45
  """Get context needed to continue a discussion.
40
46
 
@@ -49,6 +55,7 @@ async def build_context(
49
55
  page: Page number of results to return (default: 1)
50
56
  page_size: Number of results to return per page (default: 10)
51
57
  max_related: Maximum number of related results to return (default: 10)
58
+ project: Optional project name to build context from. If not provided, uses current active project.
52
59
 
53
60
  Returns:
54
61
  GraphContext containing:
@@ -68,12 +75,40 @@ async def build_context(
68
75
 
69
76
  # Research the history of a feature
70
77
  build_context("memory://features/knowledge-graph", timeframe="3 months ago")
78
+
79
+ # Build context from specific project
80
+ build_context("memory://specs/search", project="work-project")
71
81
  """
72
82
  logger.info(f"Building context from {url}")
73
- url = normalize_memory_url(url)
83
+ # URL is already validated and normalized by MemoryUrl type annotation
84
+
85
+ # Check migration status and wait briefly if needed
86
+ from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
87
+
88
+ migration_status = await wait_for_migration_or_return_status(timeout=5.0)
89
+ if migration_status: # pragma: no cover
90
+ # Return a proper GraphContext with status message
91
+ from basic_memory.schemas.memory import MemoryMetadata
92
+ from datetime import datetime
93
+
94
+ return GraphContext(
95
+ results=[],
96
+ metadata=MemoryMetadata(
97
+ depth=depth or 1,
98
+ timeframe=timeframe,
99
+ generated_at=datetime.now(),
100
+ primary_count=0,
101
+ related_count=0,
102
+ uri=migration_status, # Include status in metadata
103
+ ),
104
+ )
105
+
106
+ active_project = get_active_project(project)
107
+ project_url = active_project.project_url
108
+
74
109
  response = await call_get(
75
110
  client,
76
- f"/memory/{memory_url_path(url)}",
111
+ f"{project_url}/memory/{memory_url_path(url)}",
77
112
  params={
78
113
  "depth": depth,
79
114
  "timeframe": timeframe,
@@ -4,13 +4,14 @@ This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec
4
4
  """
5
5
 
6
6
  import json
7
- from typing import Dict, List, Any
7
+ from typing import Dict, List, Any, Optional
8
8
 
9
9
  from loguru import logger
10
10
 
11
11
  from basic_memory.mcp.async_client import client
12
12
  from basic_memory.mcp.server import mcp
13
13
  from basic_memory.mcp.tools.utils import call_put
14
+ from basic_memory.mcp.project_session import get_active_project
14
15
 
15
16
 
16
17
  @mcp.tool(
@@ -21,6 +22,7 @@ async def canvas(
21
22
  edges: List[Dict[str, Any]],
22
23
  title: str,
23
24
  folder: str,
25
+ project: Optional[str] = None,
24
26
  ) -> str:
25
27
  """Create an Obsidian canvas file with the provided nodes and edges.
26
28
 
@@ -33,7 +35,9 @@ async def canvas(
33
35
  nodes: List of node objects following JSON Canvas 1.0 spec
34
36
  edges: List of edge objects following JSON Canvas 1.0 spec
35
37
  title: The title of the canvas (will be saved as title.canvas)
36
- folder: The folder where the file should be saved
38
+ folder: Folder path relative to project root where the canvas should be saved.
39
+ Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
40
+ project: Optional project name to create canvas in. If not provided, uses current active project.
37
41
 
38
42
  Returns:
39
43
  A summary of the created canvas file
@@ -71,7 +75,17 @@ async def canvas(
71
75
  ]
72
76
  }
73
77
  ```
78
+
79
+ Examples:
80
+ # Create canvas in current project
81
+ canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
82
+
83
+ # Create canvas in specific project
84
+ canvas(nodes=[...], edges=[...], title="My Canvas", folder="diagrams", project="work-project")
74
85
  """
86
+ active_project = get_active_project(project)
87
+ project_url = active_project.project_url
88
+
75
89
  # Ensure path has .canvas extension
76
90
  file_title = title if title.endswith(".canvas") else f"{title}.canvas"
77
91
  file_path = f"{folder}/{file_title}"
@@ -84,7 +98,7 @@ async def canvas(
84
98
 
85
99
  # Write the file using the resource API
86
100
  logger.info(f"Creating canvas file: {file_path}")
87
- response = await call_put(client, f"/resource/{file_path}", json=canvas_json)
101
+ response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
88
102
 
89
103
  # Parse response
90
104
  result = response.json()