stravinsky 0.1.2__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 stravinsky might be problematic. Click here for more details.

mcp_bridge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # Stravinsky MCP Bridge
2
+ # Provides MCP tools for OAuth-authenticated access to Gemini and OpenAI models
3
+
4
+ __version__ = "0.1.0"
5
+ __author__ = "David Andrews"
@@ -0,0 +1,32 @@
1
+ # Authentication module
2
+ from .token_store import TokenStore, TokenData
3
+ from .oauth import (
4
+ perform_oauth_flow as gemini_oauth_flow,
5
+ refresh_access_token as gemini_refresh_token,
6
+ ANTIGRAVITY_CLIENT_ID,
7
+ ANTIGRAVITY_SCOPES,
8
+ ANTIGRAVITY_HEADERS,
9
+ )
10
+ from .openai_oauth import (
11
+ perform_oauth_flow as openai_oauth_flow,
12
+ refresh_access_token as openai_refresh_token,
13
+ CLIENT_ID as OPENAI_CLIENT_ID,
14
+ OPENAI_CALLBACK_PORT,
15
+ )
16
+
17
+ __all__ = [
18
+ # Token Store
19
+ "TokenStore",
20
+ "TokenData",
21
+ # Gemini OAuth
22
+ "gemini_oauth_flow",
23
+ "gemini_refresh_token",
24
+ "ANTIGRAVITY_CLIENT_ID",
25
+ "ANTIGRAVITY_SCOPES",
26
+ "ANTIGRAVITY_HEADERS",
27
+ # OpenAI OAuth
28
+ "openai_oauth_flow",
29
+ "openai_refresh_token",
30
+ "OPENAI_CLIENT_ID",
31
+ "OPENAI_CALLBACK_PORT",
32
+ ]
mcp_bridge/auth/cli.py ADDED
@@ -0,0 +1,208 @@
1
+ """
2
+ Authentication CLI for Claude Superagent MCP Bridge
3
+
4
+ Handles OAuth authentication for Gemini and OpenAI.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ import time
10
+
11
+ from .token_store import TokenStore
12
+ from .oauth import perform_oauth_flow as gemini_oauth, refresh_access_token as gemini_refresh
13
+ from .openai_oauth import (
14
+ perform_oauth_flow as openai_oauth,
15
+ refresh_access_token as openai_refresh,
16
+ )
17
+
18
+
19
+ def cmd_login(provider: str, token_store: TokenStore) -> int:
20
+ """
21
+ Perform OAuth login for a provider.
22
+
23
+ For Gemini: Uses Google OAuth with Antigravity credentials
24
+ For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
25
+ """
26
+ if provider == "gemini":
27
+ print(f"Starting Google OAuth for Gemini...")
28
+
29
+ try:
30
+ result = gemini_oauth()
31
+
32
+ expires_at = int(time.time()) + result.tokens.expires_in
33
+
34
+ token_store.set_token(
35
+ provider="gemini",
36
+ access_token=result.tokens.access_token,
37
+ refresh_token=result.tokens.refresh_token,
38
+ expires_at=expires_at,
39
+ )
40
+
41
+ print(f"\n✓ Successfully authenticated as: {result.user_info.email}")
42
+ print(f" Token expires in: {result.tokens.expires_in // 60} minutes")
43
+ return 0
44
+
45
+ except Exception as e:
46
+ print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
47
+ return 1
48
+
49
+ elif provider == "openai":
50
+ print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
51
+ print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
52
+
53
+ try:
54
+ result = openai_oauth()
55
+
56
+ expires_at = int(time.time()) + result.expires_in
57
+
58
+ token_store.set_token(
59
+ provider="openai",
60
+ access_token=result.access_token,
61
+ refresh_token=result.refresh_token,
62
+ expires_at=expires_at,
63
+ )
64
+
65
+ print(f"\n✓ Successfully authenticated with OpenAI")
66
+ print(f" Token expires in: {result.expires_in // 60} minutes")
67
+ return 0
68
+
69
+ except Exception as e:
70
+ print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
71
+ print("\nTroubleshooting:")
72
+ print(" - Ensure you have a ChatGPT Plus/Pro subscription")
73
+ print(" - Stop Codex CLI if running: killall codex")
74
+ print(" - Or use Codex CLI directly: codex login")
75
+ return 1
76
+
77
+ else:
78
+ print(f"Unknown provider: {provider}", file=sys.stderr)
79
+ print("Supported providers: gemini, openai")
80
+ return 1
81
+
82
+
83
+ def cmd_logout(provider: str, token_store: TokenStore) -> int:
84
+ """Remove stored tokens for a provider."""
85
+ token_store.delete_token(provider)
86
+ print(f"✓ Logged out from {provider}")
87
+ return 0
88
+
89
+
90
+ def cmd_status(token_store: TokenStore) -> int:
91
+ """Show authentication status for all providers."""
92
+ print("Authentication Status:\n")
93
+
94
+ for provider in ["gemini", "openai"]:
95
+ has_token = token_store.has_valid_token(provider)
96
+ status = "✓ Authenticated" if has_token else "✗ Not authenticated"
97
+ print(f" {provider.capitalize()}: {status}")
98
+
99
+ if has_token:
100
+ token = token_store.get_token(provider)
101
+ if token and token.get("expires_at"):
102
+ expires = token["expires_at"]
103
+ remaining = expires - int(time.time())
104
+ if remaining > 0:
105
+ hours = remaining // 3600
106
+ minutes = (remaining % 3600) // 60
107
+ print(f" Expires in: {hours}h {minutes}m")
108
+ else:
109
+ print(f" Token expired")
110
+
111
+ print()
112
+ return 0
113
+
114
+
115
+ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
116
+ """Refresh access token for a provider."""
117
+ token = token_store.get_token(provider)
118
+ if not token or not token.get("refresh_token"):
119
+ print(f"No refresh token found for {provider}")
120
+ print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
121
+ return 1
122
+
123
+ try:
124
+ print(f"Refreshing {provider} token...")
125
+
126
+ if provider == "gemini":
127
+ result = gemini_refresh(token["refresh_token"])
128
+ elif provider == "openai":
129
+ result = openai_refresh(token["refresh_token"])
130
+ else:
131
+ print(f"Refresh not supported for {provider}")
132
+ return 1
133
+
134
+ expires_at = int(time.time()) + result.expires_in
135
+
136
+ token_store.set_token(
137
+ provider=provider,
138
+ access_token=result.access_token,
139
+ refresh_token=result.refresh_token or token["refresh_token"],
140
+ expires_at=expires_at,
141
+ )
142
+
143
+ print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
144
+ return 0
145
+
146
+ except Exception as e:
147
+ print(f"✗ Refresh failed: {e}", file=sys.stderr)
148
+ return 1
149
+
150
+
151
+ def main():
152
+ """CLI entry point."""
153
+ parser = argparse.ArgumentParser(
154
+ description="Stravinsky Authentication CLI",
155
+ prog="python -m mcp_bridge.auth.cli",
156
+ )
157
+
158
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
159
+
160
+ # login command
161
+ login_parser = subparsers.add_parser("login", help="Authenticate with a provider")
162
+ login_parser.add_argument(
163
+ "provider",
164
+ choices=["gemini", "openai"],
165
+ help="Provider to authenticate with",
166
+ )
167
+
168
+ # logout command
169
+ logout_parser = subparsers.add_parser("logout", help="Remove stored credentials")
170
+ logout_parser.add_argument(
171
+ "provider",
172
+ choices=["gemini", "openai"],
173
+ help="Provider to logout from",
174
+ )
175
+
176
+ # status command
177
+ subparsers.add_parser("status", help="Show authentication status")
178
+
179
+ # refresh command
180
+ refresh_parser = subparsers.add_parser("refresh", help="Refresh access token")
181
+ refresh_parser.add_argument(
182
+ "provider",
183
+ choices=["gemini", "openai"],
184
+ help="Provider to refresh token for",
185
+ )
186
+
187
+ args = parser.parse_args()
188
+
189
+ if not args.command:
190
+ parser.print_help()
191
+ return 1
192
+
193
+ token_store = TokenStore()
194
+
195
+ if args.command == "login":
196
+ return cmd_login(args.provider, token_store)
197
+ elif args.command == "logout":
198
+ return cmd_logout(args.provider, token_store)
199
+ elif args.command == "status":
200
+ return cmd_status(token_store)
201
+ elif args.command == "refresh":
202
+ return cmd_refresh(args.provider, token_store)
203
+
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main())
@@ -0,0 +1,418 @@
1
+ """
2
+ Antigravity OAuth 2.0 Implementation with PKCE
3
+
4
+ Implements Google OAuth for Antigravity (Gemini) authentication.
5
+ Based on the original TypeScript implementation from Stravinsky.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import secrets
13
+ import threading
14
+ import webbrowser
15
+ from dataclasses import dataclass
16
+ from http.server import HTTPServer, BaseHTTPRequestHandler
17
+ from typing import Any
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+
20
+ import httpx
21
+
22
+
23
+ # OAuth 2.0 Client Credentials (from constants.ts)
24
+ ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
25
+ ANTIGRAVITY_CLIENT_SECRET = "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"
26
+
27
+ # OAuth Callback
28
+ ANTIGRAVITY_CALLBACK_PORT = 51121
29
+
30
+ # OAuth Scopes
31
+ ANTIGRAVITY_SCOPES = [
32
+ "https://www.googleapis.com/auth/cloud-platform",
33
+ "https://www.googleapis.com/auth/userinfo.email",
34
+ "https://www.googleapis.com/auth/userinfo.profile",
35
+ "https://www.googleapis.com/auth/cclog",
36
+ "https://www.googleapis.com/auth/experimentsandconfigs",
37
+ ]
38
+
39
+ # API Endpoints
40
+ ANTIGRAVITY_ENDPOINTS = [
41
+ "https://daily-cloudcode-pa.sandbox.googleapis.com", # dev
42
+ "https://autopush-cloudcode-pa.sandbox.googleapis.com", # staging
43
+ "https://cloudcode-pa.googleapis.com", # prod
44
+ ]
45
+
46
+ # Default Project ID
47
+ ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
48
+
49
+ # API Version
50
+ ANTIGRAVITY_API_VERSION = "v1internal"
51
+
52
+ # Request Headers
53
+ ANTIGRAVITY_HEADERS = {
54
+ "User-Agent": "google-api-nodejs-client/9.15.1",
55
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
56
+ "Client-Metadata": json.dumps({
57
+ "ideType": "IDE_UNSPECIFIED",
58
+ "platform": "PLATFORM_UNSPECIFIED",
59
+ "pluginType": "GEMINI",
60
+ }),
61
+ }
62
+
63
+ # Google OAuth endpoints
64
+ GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
65
+ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
66
+ GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
67
+
68
+ # Token refresh buffer (60 seconds before expiry)
69
+ TOKEN_REFRESH_BUFFER_MS = 60_000
70
+
71
+
72
+ @dataclass
73
+ class PKCEPair:
74
+ """PKCE verifier and challenge pair."""
75
+ verifier: str
76
+ challenge: str
77
+ method: str = "S256"
78
+
79
+
80
+ @dataclass
81
+ class TokenResult:
82
+ """OAuth token exchange result."""
83
+ access_token: str
84
+ refresh_token: str
85
+ expires_in: int
86
+ token_type: str
87
+
88
+
89
+ @dataclass
90
+ class UserInfo:
91
+ """User info from Google."""
92
+ email: str
93
+ name: str | None = None
94
+ picture: str | None = None
95
+
96
+
97
+ @dataclass
98
+ class OAuthResult:
99
+ """Complete OAuth flow result."""
100
+ tokens: TokenResult
101
+ user_info: UserInfo
102
+ verifier: str
103
+
104
+
105
+ def generate_pkce_pair() -> PKCEPair:
106
+ """
107
+ Generate PKCE verifier and challenge pair.
108
+
109
+ Uses SHA-256 for the challenge (S256 method).
110
+ """
111
+ # Generate 32-byte random verifier
112
+ verifier_bytes = secrets.token_bytes(32)
113
+ verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode("ascii")
114
+
115
+ # Generate challenge from verifier using SHA-256
116
+ challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
117
+ challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
118
+
119
+ return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
120
+
121
+
122
+ def encode_state(verifier: str, project_id: str | None = None) -> str:
123
+ """Encode OAuth state as URL-safe base64 JSON."""
124
+ state = {"verifier": verifier}
125
+ if project_id:
126
+ state["projectId"] = project_id
127
+ return base64.urlsafe_b64encode(json.dumps(state).encode()).decode()
128
+
129
+
130
+ def decode_state(encoded: str) -> dict[str, Any]:
131
+ """Decode OAuth state from base64 JSON."""
132
+ # Handle both base64url and standard base64
133
+ encoded = encoded.replace("-", "+").replace("_", "/")
134
+ # Add padding
135
+ padding = 4 - len(encoded) % 4
136
+ if padding != 4:
137
+ encoded += "=" * padding
138
+
139
+ try:
140
+ decoded = base64.b64decode(encoded).decode("utf-8")
141
+ return json.loads(decoded)
142
+ except Exception:
143
+ return {}
144
+
145
+
146
+ def build_auth_url(
147
+ port: int,
148
+ project_id: str | None = None,
149
+ client_id: str = ANTIGRAVITY_CLIENT_ID,
150
+ ) -> tuple[str, str]:
151
+ """
152
+ Build Google OAuth authorization URL with PKCE.
153
+
154
+ Returns:
155
+ Tuple of (auth_url, pkce_verifier)
156
+ """
157
+ pkce = generate_pkce_pair()
158
+
159
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
160
+ state = encode_state(pkce.verifier, project_id)
161
+
162
+ params = {
163
+ "client_id": client_id,
164
+ "redirect_uri": redirect_uri,
165
+ "response_type": "code",
166
+ "scope": " ".join(ANTIGRAVITY_SCOPES),
167
+ "state": state,
168
+ "code_challenge": pkce.challenge,
169
+ "code_challenge_method": "S256",
170
+ "access_type": "offline",
171
+ "prompt": "consent",
172
+ }
173
+
174
+ url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
175
+ return url, pkce.verifier
176
+
177
+
178
+ def exchange_code(
179
+ code: str,
180
+ verifier: str,
181
+ port: int,
182
+ client_id: str = ANTIGRAVITY_CLIENT_ID,
183
+ client_secret: str = ANTIGRAVITY_CLIENT_SECRET,
184
+ ) -> TokenResult:
185
+ """
186
+ Exchange authorization code for tokens.
187
+
188
+ Args:
189
+ code: Authorization code from OAuth callback
190
+ verifier: PKCE verifier from initial auth request
191
+ port: Callback server port
192
+
193
+ Returns:
194
+ Token exchange result with access and refresh tokens.
195
+ """
196
+ redirect_uri = f"http://localhost:{port}/oauth-callback"
197
+
198
+ data = {
199
+ "client_id": client_id,
200
+ "client_secret": client_secret,
201
+ "code": code,
202
+ "grant_type": "authorization_code",
203
+ "redirect_uri": redirect_uri,
204
+ "code_verifier": verifier,
205
+ }
206
+
207
+ with httpx.Client() as client:
208
+ response = client.post(
209
+ GOOGLE_TOKEN_URL,
210
+ data=data,
211
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
212
+ )
213
+
214
+ if response.status_code != 200:
215
+ raise Exception(f"Token exchange failed: {response.status_code} - {response.text}")
216
+
217
+ result = response.json()
218
+
219
+ return TokenResult(
220
+ access_token=result["access_token"],
221
+ refresh_token=result.get("refresh_token", ""),
222
+ expires_in=result.get("expires_in", 3600),
223
+ token_type=result.get("token_type", "Bearer"),
224
+ )
225
+
226
+
227
+ def refresh_access_token(
228
+ refresh_token: str,
229
+ client_id: str = ANTIGRAVITY_CLIENT_ID,
230
+ client_secret: str = ANTIGRAVITY_CLIENT_SECRET,
231
+ ) -> TokenResult:
232
+ """
233
+ Refresh an access token using a refresh token.
234
+
235
+ Args:
236
+ refresh_token: Valid refresh token
237
+
238
+ Returns:
239
+ New token result.
240
+ """
241
+ data = {
242
+ "client_id": client_id,
243
+ "client_secret": client_secret,
244
+ "refresh_token": refresh_token,
245
+ "grant_type": "refresh_token",
246
+ }
247
+
248
+ with httpx.Client() as client:
249
+ response = client.post(
250
+ GOOGLE_TOKEN_URL,
251
+ data=data,
252
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
253
+ )
254
+
255
+ if response.status_code != 200:
256
+ raise Exception(f"Token refresh failed: {response.status_code} - {response.text}")
257
+
258
+ result = response.json()
259
+
260
+ return TokenResult(
261
+ access_token=result["access_token"],
262
+ refresh_token=refresh_token, # Keep original refresh token
263
+ expires_in=result.get("expires_in", 3600),
264
+ token_type=result.get("token_type", "Bearer"),
265
+ )
266
+
267
+
268
+ def fetch_user_info(access_token: str) -> UserInfo:
269
+ """
270
+ Fetch user info from Google's userinfo API.
271
+
272
+ Args:
273
+ access_token: Valid access token
274
+
275
+ Returns:
276
+ User info with email, name, and picture.
277
+ """
278
+ with httpx.Client() as client:
279
+ response = client.get(
280
+ f"{GOOGLE_USERINFO_URL}?alt=json",
281
+ headers={"Authorization": f"Bearer {access_token}"},
282
+ )
283
+
284
+ if response.status_code != 200:
285
+ raise Exception(f"Failed to fetch user info: {response.status_code}")
286
+
287
+ data = response.json()
288
+
289
+ return UserInfo(
290
+ email=data.get("email", ""),
291
+ name=data.get("name"),
292
+ picture=data.get("picture"),
293
+ )
294
+
295
+
296
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
297
+ """HTTP request handler for OAuth callback."""
298
+
299
+ callback_result: dict[str, Any] = {}
300
+ server_ready = threading.Event()
301
+
302
+ def log_message(self, format, *args):
303
+ """Suppress HTTP server logs."""
304
+ pass
305
+
306
+ def do_GET(self):
307
+ """Handle OAuth callback GET request."""
308
+ parsed = urlparse(self.path)
309
+
310
+ if parsed.path == "/oauth-callback":
311
+ params = parse_qs(parsed.query)
312
+
313
+ OAuthCallbackHandler.callback_result = {
314
+ "code": params.get("code", [""])[0],
315
+ "state": params.get("state", [""])[0],
316
+ "error": params.get("error", [None])[0],
317
+ }
318
+
319
+ if OAuthCallbackHandler.callback_result["code"] and not OAuthCallbackHandler.callback_result["error"]:
320
+ body = b"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>"
321
+ else:
322
+ body = b"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
323
+
324
+ self.send_response(200)
325
+ self.send_header("Content-Type", "text/html")
326
+ self.send_header("Content-Length", str(len(body)))
327
+ self.end_headers()
328
+ self.wfile.write(body)
329
+
330
+ # Signal completion
331
+ OAuthCallbackHandler.server_ready.set()
332
+ else:
333
+ self.send_response(404)
334
+ self.end_headers()
335
+
336
+
337
+ def perform_oauth_flow(
338
+ project_id: str | None = None,
339
+ timeout: int = 300,
340
+ ) -> OAuthResult:
341
+ """
342
+ Perform full OAuth flow with browser-based authentication.
343
+
344
+ 1. Start local callback server
345
+ 2. Open browser for Google auth
346
+ 3. Wait for callback
347
+ 4. Exchange code for tokens
348
+ 5. Fetch user info
349
+
350
+ Args:
351
+ project_id: Optional project ID for state
352
+ timeout: Timeout in seconds (default 5 minutes)
353
+
354
+ Returns:
355
+ Complete OAuth result with tokens and user info.
356
+ """
357
+ # Reset callback state
358
+ OAuthCallbackHandler.callback_result = {}
359
+ OAuthCallbackHandler.server_ready.clear()
360
+
361
+ # Start callback server
362
+ server = HTTPServer(("localhost", 0), OAuthCallbackHandler)
363
+ port = server.server_address[1]
364
+
365
+ server_thread = threading.Thread(target=lambda: server.handle_request())
366
+ server_thread.daemon = True
367
+ server_thread.start()
368
+
369
+ try:
370
+ # Build auth URL and open browser
371
+ auth_url, verifier = build_auth_url(port, project_id)
372
+
373
+ print(f"\nOpening browser for Google authentication...")
374
+ print(f"If browser doesn't open, visit:\n{auth_url}\n")
375
+
376
+ webbrowser.open(auth_url)
377
+
378
+ # Wait for callback
379
+ if not OAuthCallbackHandler.server_ready.wait(timeout):
380
+ raise Exception("OAuth callback timeout")
381
+
382
+ result = OAuthCallbackHandler.callback_result
383
+
384
+ if result.get("error"):
385
+ raise Exception(f"OAuth error: {result['error']}")
386
+
387
+ if not result.get("code"):
388
+ raise Exception("No authorization code received")
389
+
390
+ # Verify state
391
+ state = decode_state(result["state"])
392
+ if state.get("verifier") != verifier:
393
+ raise Exception("PKCE verifier mismatch - possible security issue")
394
+
395
+ # Exchange code for tokens
396
+ tokens = exchange_code(result["code"], verifier, port)
397
+
398
+ # Fetch user info
399
+ user_info = fetch_user_info(tokens.access_token)
400
+
401
+ print(f"✓ Authenticated as: {user_info.email}")
402
+
403
+ return OAuthResult(tokens=tokens, user_info=user_info, verifier=verifier)
404
+
405
+ finally:
406
+ server.server_close()
407
+
408
+
409
+ if __name__ == "__main__":
410
+ # Test the OAuth flow
411
+ try:
412
+ result = perform_oauth_flow()
413
+ print(f"\nAccess Token: {result.tokens.access_token[:20]}...")
414
+ print(f"Refresh Token: {result.tokens.refresh_token[:20]}...")
415
+ print(f"Expires In: {result.tokens.expires_in}s")
416
+ print(f"User: {result.user_info.email}")
417
+ except Exception as e:
418
+ print(f"OAuth failed: {e}")