basic-memory 0.12.3__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.
- basic_memory/__init__.py +2 -1
- basic_memory/alembic/env.py +1 -1
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +108 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +104 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +0 -6
- basic_memory/api/app.py +43 -13
- basic_memory/api/routers/__init__.py +4 -2
- basic_memory/api/routers/directory_router.py +63 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +139 -37
- basic_memory/api/routers/management_router.py +78 -0
- basic_memory/api/routers/memory_router.py +6 -62
- basic_memory/api/routers/project_router.py +234 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/search_router.py +3 -21
- basic_memory/api/routers/utils.py +130 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/cli/app.py +20 -21
- basic_memory/cli/commands/__init__.py +2 -1
- basic_memory/cli/commands/auth.py +136 -0
- basic_memory/cli/commands/db.py +3 -3
- basic_memory/cli/commands/import_chatgpt.py +31 -207
- basic_memory/cli/commands/import_claude_conversations.py +16 -142
- basic_memory/cli/commands/import_claude_projects.py +33 -143
- basic_memory/cli/commands/import_memory_json.py +26 -83
- basic_memory/cli/commands/mcp.py +71 -18
- basic_memory/cli/commands/project.py +102 -70
- basic_memory/cli/commands/status.py +19 -9
- basic_memory/cli/commands/sync.py +44 -58
- basic_memory/cli/commands/tool.py +6 -6
- basic_memory/cli/main.py +1 -5
- basic_memory/config.py +143 -87
- basic_memory/db.py +6 -4
- basic_memory/deps.py +227 -30
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +222 -0
- basic_memory/importers/claude_conversations_importer.py +172 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +93 -0
- basic_memory/importers/utils.py +58 -0
- basic_memory/markdown/entity_parser.py +5 -2
- basic_memory/mcp/auth_provider.py +270 -0
- basic_memory/mcp/external_auth_provider.py +321 -0
- basic_memory/mcp/project_session.py +103 -0
- basic_memory/mcp/prompts/__init__.py +2 -0
- basic_memory/mcp/prompts/continue_conversation.py +18 -68
- basic_memory/mcp/prompts/recent_activity.py +20 -4
- basic_memory/mcp/prompts/search.py +14 -140
- basic_memory/mcp/prompts/sync_status.py +116 -0
- basic_memory/mcp/prompts/utils.py +3 -3
- basic_memory/mcp/{tools → resources}/project_info.py +6 -2
- basic_memory/mcp/server.py +86 -13
- basic_memory/mcp/supabase_auth_provider.py +463 -0
- basic_memory/mcp/tools/__init__.py +24 -0
- basic_memory/mcp/tools/build_context.py +43 -8
- basic_memory/mcp/tools/canvas.py +17 -3
- basic_memory/mcp/tools/delete_note.py +168 -5
- basic_memory/mcp/tools/edit_note.py +303 -0
- basic_memory/mcp/tools/list_directory.py +154 -0
- basic_memory/mcp/tools/move_note.py +299 -0
- basic_memory/mcp/tools/project_management.py +332 -0
- basic_memory/mcp/tools/read_content.py +15 -6
- basic_memory/mcp/tools/read_note.py +26 -7
- basic_memory/mcp/tools/recent_activity.py +11 -2
- basic_memory/mcp/tools/search.py +189 -8
- basic_memory/mcp/tools/sync_status.py +254 -0
- basic_memory/mcp/tools/utils.py +184 -12
- basic_memory/mcp/tools/view_note.py +66 -0
- basic_memory/mcp/tools/write_note.py +24 -17
- basic_memory/models/__init__.py +3 -2
- basic_memory/models/knowledge.py +16 -4
- basic_memory/models/project.py +78 -0
- basic_memory/models/search.py +8 -5
- basic_memory/repository/__init__.py +2 -0
- basic_memory/repository/entity_repository.py +8 -3
- basic_memory/repository/observation_repository.py +35 -3
- basic_memory/repository/project_info_repository.py +3 -2
- basic_memory/repository/project_repository.py +85 -0
- basic_memory/repository/relation_repository.py +8 -2
- basic_memory/repository/repository.py +107 -15
- basic_memory/repository/search_repository.py +192 -54
- basic_memory/schemas/__init__.py +6 -0
- basic_memory/schemas/base.py +33 -5
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +34 -0
- basic_memory/schemas/memory.py +84 -13
- basic_memory/schemas/project_info.py +112 -2
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +56 -2
- basic_memory/schemas/search.py +1 -1
- basic_memory/services/__init__.py +2 -1
- basic_memory/services/context_service.py +208 -95
- basic_memory/services/directory_service.py +167 -0
- basic_memory/services/entity_service.py +399 -6
- basic_memory/services/exceptions.py +6 -0
- basic_memory/services/file_service.py +14 -15
- basic_memory/services/initialization.py +170 -66
- basic_memory/services/link_resolver.py +35 -12
- basic_memory/services/migration_service.py +168 -0
- basic_memory/services/project_service.py +671 -0
- basic_memory/services/search_service.py +77 -2
- basic_memory/services/sync_status_service.py +181 -0
- basic_memory/sync/background_sync.py +25 -0
- basic_memory/sync/sync_service.py +102 -21
- basic_memory/sync/watch_service.py +63 -39
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/METADATA +24 -2
- basic_memory-0.13.0.dist-info/RECORD +138 -0
- basic_memory/api/routers/project_info_router.py +0 -274
- basic_memory/mcp/main.py +0 -24
- basic_memory-0.12.3.dist-info/RECORD +0 -100
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/WHEEL +0 -0
- {basic_memory-0.12.3.dist-info → basic_memory-0.13.0.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.12.3.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
|
-
- "
|
|
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
|
-
|
|
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,
|
basic_memory/mcp/tools/canvas.py
CHANGED
|
@@ -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:
|
|
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()
|