basic-memory 0.14.2__py3-none-any.whl → 0.14.3__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 +1 -1
- basic_memory/alembic/env.py +3 -1
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +41 -0
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +46 -31
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/importers/chatgpt_importer.py +19 -9
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +12 -0
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/project.py +3 -3
- basic_memory/repository/project_repository.py +18 -0
- basic_memory/schemas/importer.py +1 -0
- basic_memory/services/entity_service.py +49 -3
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +85 -28
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +50 -1
- basic_memory/utils.py +105 -4
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/RECORD +45 -51
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""Sync status prompt for Basic Memory MCP server."""
|
|
2
|
-
|
|
3
|
-
from basic_memory.mcp.server import mcp
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@mcp.prompt(
|
|
7
|
-
description="""Get sync status with recommendations for AI assistants.
|
|
8
|
-
|
|
9
|
-
This prompt provides both current sync status and guidance on how
|
|
10
|
-
AI assistants should respond when sync operations are in progress or completed.
|
|
11
|
-
""",
|
|
12
|
-
)
|
|
13
|
-
async def sync_status_prompt() -> str:
|
|
14
|
-
"""Get sync status with AI assistant guidance.
|
|
15
|
-
Returns:
|
|
16
|
-
Formatted sync status with AI assistant guidance
|
|
17
|
-
"""
|
|
18
|
-
try: # pragma: no cover
|
|
19
|
-
from basic_memory.services.migration_service import migration_manager
|
|
20
|
-
|
|
21
|
-
state = migration_manager.state
|
|
22
|
-
|
|
23
|
-
# Build status report
|
|
24
|
-
lines = [
|
|
25
|
-
"# Basic Memory Sync Status",
|
|
26
|
-
"",
|
|
27
|
-
f"**Current Status**: {state.status.value.replace('_', ' ').title()}",
|
|
28
|
-
f"**System Ready**: {'Yes' if migration_manager.is_ready else 'No'}",
|
|
29
|
-
"",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
if migration_manager.is_ready:
|
|
33
|
-
lines.extend(
|
|
34
|
-
[
|
|
35
|
-
"✅ **All sync operations completed** - System is fully operational",
|
|
36
|
-
"",
|
|
37
|
-
"All Basic Memory tools are available and functioning normally.",
|
|
38
|
-
"File indexing is complete and knowledge graphs are up to date.",
|
|
39
|
-
"You can proceed with any knowledge management tasks.",
|
|
40
|
-
]
|
|
41
|
-
)
|
|
42
|
-
else:
|
|
43
|
-
lines.append(f"**Status Message**: {state.message}")
|
|
44
|
-
|
|
45
|
-
if state.status.value == "in_progress":
|
|
46
|
-
if state.projects_total > 0:
|
|
47
|
-
progress = f" ({state.projects_migrated}/{state.projects_total})"
|
|
48
|
-
lines.append(f"**Progress**: {progress}")
|
|
49
|
-
|
|
50
|
-
lines.extend(
|
|
51
|
-
[
|
|
52
|
-
"",
|
|
53
|
-
"🔄 **File synchronization in progress** - Processing files and building knowledge graphs",
|
|
54
|
-
"",
|
|
55
|
-
"**Impact**: Some tools may show status messages instead of normal responses",
|
|
56
|
-
"until sync completes (usually 1-3 minutes).",
|
|
57
|
-
]
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
elif state.status.value == "failed":
|
|
61
|
-
lines.extend(
|
|
62
|
-
[
|
|
63
|
-
"",
|
|
64
|
-
f"❌ **Sync failed**: {state.error or 'Unknown error'}",
|
|
65
|
-
"",
|
|
66
|
-
"**Impact**: System may have limited functionality until issue is resolved.",
|
|
67
|
-
]
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
# Add AI assistant recommendations
|
|
71
|
-
if not migration_manager.is_ready:
|
|
72
|
-
lines.extend(
|
|
73
|
-
[
|
|
74
|
-
"",
|
|
75
|
-
"---",
|
|
76
|
-
"",
|
|
77
|
-
"## AI Assistant Recommendations",
|
|
78
|
-
"",
|
|
79
|
-
"**When sync is in progress:**",
|
|
80
|
-
"- Inform the user about the background file processing",
|
|
81
|
-
"- Suggest using `sync_status()` tool to check progress",
|
|
82
|
-
"- Explain that tools will work normally once sync completes",
|
|
83
|
-
"- Avoid creating complex workflows until sync is done",
|
|
84
|
-
"",
|
|
85
|
-
"**What to tell users:**",
|
|
86
|
-
"- 'Basic Memory is processing your files and building knowledge graphs'",
|
|
87
|
-
"- 'This usually takes 1-3 minutes depending on your content size'",
|
|
88
|
-
"- 'You can check progress anytime with the sync_status tool'",
|
|
89
|
-
"- 'Full functionality will be available once processing completes'",
|
|
90
|
-
"",
|
|
91
|
-
"**User-friendly language:**",
|
|
92
|
-
"- Say 'processing files' instead of 'migration' or 'sync'",
|
|
93
|
-
"- Say 'building knowledge graphs' instead of 'indexing'",
|
|
94
|
-
"- Say 'setting up your knowledge base' instead of 'running migrations'",
|
|
95
|
-
]
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
return "\n".join(lines)
|
|
99
|
-
|
|
100
|
-
except Exception as e: # pragma: no cover
|
|
101
|
-
return f"""# Sync Status - Error
|
|
102
|
-
|
|
103
|
-
❌ **Unable to check sync status**: {str(e)}
|
|
104
|
-
|
|
105
|
-
## AI Assistant Recommendations
|
|
106
|
-
|
|
107
|
-
**When status is unavailable:**
|
|
108
|
-
- Assume the system is likely working normally
|
|
109
|
-
- Try proceeding with normal operations
|
|
110
|
-
- If users report issues, suggest checking logs or restarting
|
|
111
|
-
- Use user-friendly language about 'setting up the knowledge base'
|
|
112
|
-
"""
|
|
@@ -1,463 +0,0 @@
|
|
|
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, timezone
|
|
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.now(timezone.utc) + 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.now(timezone.utc).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.now(timezone.utc) + timedelta(hours=1),
|
|
457
|
-
"iat": datetime.now(timezone.utc),
|
|
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")
|