golf-mcp 0.1.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 golf-mcp might be problematic. Click here for more details.

Files changed (41) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +109 -0
  3. golf/auth/helpers.py +56 -0
  4. golf/auth/oauth.py +798 -0
  5. golf/auth/provider.py +110 -0
  6. golf/cli/__init__.py +1 -0
  7. golf/cli/main.py +223 -0
  8. golf/commands/__init__.py +3 -0
  9. golf/commands/build.py +78 -0
  10. golf/commands/init.py +197 -0
  11. golf/commands/run.py +68 -0
  12. golf/core/__init__.py +1 -0
  13. golf/core/builder.py +1169 -0
  14. golf/core/builder_auth.py +157 -0
  15. golf/core/builder_telemetry.py +208 -0
  16. golf/core/config.py +205 -0
  17. golf/core/parser.py +509 -0
  18. golf/core/transformer.py +168 -0
  19. golf/examples/__init__.py +1 -0
  20. golf/examples/basic/.env +3 -0
  21. golf/examples/basic/.env.example +3 -0
  22. golf/examples/basic/README.md +117 -0
  23. golf/examples/basic/golf.json +9 -0
  24. golf/examples/basic/pre_build.py +28 -0
  25. golf/examples/basic/prompts/welcome.py +30 -0
  26. golf/examples/basic/resources/current_time.py +41 -0
  27. golf/examples/basic/resources/info.py +27 -0
  28. golf/examples/basic/resources/weather/common.py +48 -0
  29. golf/examples/basic/resources/weather/current.py +32 -0
  30. golf/examples/basic/resources/weather/forecast.py +32 -0
  31. golf/examples/basic/tools/github_user.py +67 -0
  32. golf/examples/basic/tools/hello.py +29 -0
  33. golf/examples/basic/tools/payments/charge.py +50 -0
  34. golf/examples/basic/tools/payments/common.py +34 -0
  35. golf/examples/basic/tools/payments/refund.py +50 -0
  36. golf_mcp-0.1.0.dist-info/METADATA +78 -0
  37. golf_mcp-0.1.0.dist-info/RECORD +41 -0
  38. golf_mcp-0.1.0.dist-info/WHEEL +5 -0
  39. golf_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  40. golf_mcp-0.1.0.dist-info/licenses/LICENSE +201 -0
  41. golf_mcp-0.1.0.dist-info/top_level.txt +1 -0
golf/auth/oauth.py ADDED
@@ -0,0 +1,798 @@
1
+ """OAuth provider implementation for GolfMCP.
2
+
3
+ This module provides an implementation of the MCP OAuthAuthorizationServerProvider
4
+ interface for GolfMCP servers. It handles the OAuth 2.0 authentication flow,
5
+ token management, and client registration.
6
+ """
7
+
8
+ import time
9
+ import uuid
10
+ import jwt
11
+ import httpx
12
+ import os
13
+ from typing import Dict, List, Optional, Any, Union
14
+ from datetime import datetime
15
+
16
+ from mcp.server.auth.provider import (
17
+ OAuthAuthorizationServerProvider,
18
+ AccessToken,
19
+ RefreshToken,
20
+ AuthorizationCode,
21
+ RegistrationError,
22
+ AuthorizationParams
23
+ )
24
+ from mcp.shared.auth import (
25
+ OAuthToken,
26
+ OAuthClientInformationFull,
27
+ )
28
+ from starlette.responses import RedirectResponse
29
+
30
+ from .provider import ProviderConfig
31
+
32
+
33
+ class TokenStorage:
34
+ """Simple in-memory token storage.
35
+
36
+ This class provides a simple in-memory storage for OAuth tokens,
37
+ authorization codes, and client information. In a production
38
+ environment, this should be replaced with a persistent storage
39
+ solution.
40
+ """
41
+
42
+ def __init__(self):
43
+ """Initialize the token storage."""
44
+ self.auth_codes = {} # code_str -> AuthorizationCode
45
+ self.refresh_tokens = {} # token_str -> RefreshToken
46
+ self.access_tokens = {} # token_str -> AccessToken
47
+ self.clients = {} # client_id -> OAuthClientInformationFull
48
+ self.provider_tokens = {} # mcp_access_token_str -> provider_access_token_str
49
+ self.auth_code_to_provider_token = {} # auth_code_str -> provider_access_token_str
50
+
51
+ def store_auth_code(self, code: str, auth_code_obj: AuthorizationCode) -> None: # Renamed auth_code to auth_code_obj for clarity
52
+ """Store an authorization code.
53
+
54
+ Args:
55
+ code: The authorization code string
56
+ auth_code_obj: The authorization code object
57
+ """
58
+ self.auth_codes[code] = auth_code_obj
59
+
60
+ def get_auth_code(self, code: str) -> Optional[AuthorizationCode]:
61
+ """Get an authorization code by value.
62
+
63
+ Args:
64
+ code: The authorization code string
65
+
66
+ Returns:
67
+ The authorization code object or None if not found
68
+ """
69
+ return self.auth_codes.get(code)
70
+
71
+ def delete_auth_code(self, code: str) -> None:
72
+ """Delete an authorization code and its associated provider token mapping.
73
+
74
+ Args:
75
+ code: The authorization code string
76
+ """
77
+ if code in self.auth_codes:
78
+ del self.auth_codes[code]
79
+ if code in self.auth_code_to_provider_token:
80
+ del self.auth_code_to_provider_token[code]
81
+
82
+ def store_auth_code_provider_token_mapping(self, auth_code_str: str, provider_token: str) -> None:
83
+ """Store a mapping from an auth_code string to a provider_token string."""
84
+ self.auth_code_to_provider_token[auth_code_str] = provider_token
85
+
86
+ def get_provider_token_for_auth_code(self, auth_code_str: str) -> Optional[str]:
87
+ """Retrieve a provider_token string using an auth_code string."""
88
+ return self.auth_code_to_provider_token.get(auth_code_str)
89
+
90
+ def store_client(self, client_id: str, client: OAuthClientInformationFull) -> None:
91
+ """Store client information.
92
+
93
+ Args:
94
+ client_id: The client ID
95
+ client: The client information
96
+ """
97
+ self.clients[client_id] = client
98
+
99
+ def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
100
+ """Get client information by ID.
101
+
102
+ Args:
103
+ client_id: The client ID
104
+
105
+ Returns:
106
+ The client information or None if not found
107
+ """
108
+ # _diag_logger.info(f"TokenStorage: get_client called for client_id '{client_id}'. Known clients: {list(self.clients.keys())}") # Optional: uncomment for debugging
109
+ return self.clients.get(client_id)
110
+
111
+ def store_refresh_token(self, token: str, refresh_token: RefreshToken) -> None:
112
+ """Store a refresh token.
113
+
114
+ Args:
115
+ token: The refresh token string
116
+ refresh_token: The refresh token object
117
+ """
118
+ self.refresh_tokens[token] = refresh_token
119
+
120
+ def get_refresh_token(self, token: str) -> Optional[RefreshToken]:
121
+ """Get a refresh token by value.
122
+
123
+ Args:
124
+ token: The refresh token string
125
+
126
+ Returns:
127
+ The refresh token object or None if not found
128
+ """
129
+ return self.refresh_tokens.get(token)
130
+
131
+ def delete_refresh_token(self, token: str) -> None:
132
+ """Delete a refresh token.
133
+
134
+ Args:
135
+ token: The refresh token string
136
+ """
137
+ if token in self.refresh_tokens:
138
+ del self.refresh_tokens[token]
139
+
140
+ def store_access_token(self, token: str, access_token: AccessToken) -> None:
141
+ """Store an access token.
142
+
143
+ Args:
144
+ token: The access token string
145
+ access_token: The access token object
146
+ """
147
+ self.access_tokens[token] = access_token
148
+
149
+ def get_access_token(self, token: str) -> Optional[AccessToken]:
150
+ """Get an access token by value.
151
+
152
+ Args:
153
+ token: The access token string
154
+
155
+ Returns:
156
+ The access token object or None if not found
157
+ """
158
+ return self.access_tokens.get(token)
159
+
160
+ def delete_access_token(self, token: str) -> None:
161
+ """Delete an access token.
162
+
163
+ Args:
164
+ token: The access token string
165
+ """
166
+ if token in self.access_tokens:
167
+ del self.access_tokens[token]
168
+
169
+ def store_provider_token(self, mcp_token: str, provider_token: str) -> None:
170
+ """Store a provider token mapping.
171
+
172
+ Args:
173
+ mcp_token: The MCP token string
174
+ provider_token: The provider token string (e.g., GitHub token)
175
+ """
176
+ self.provider_tokens[mcp_token] = provider_token
177
+
178
+ def get_provider_token(self, mcp_token: str) -> Optional[str]:
179
+ """Get the provider token associated with an MCP token.
180
+
181
+ This is a non-standard method to allow access to the provider token
182
+ (e.g., GitHub token) for a given MCP token. This can be used by
183
+ tools that need to access provider APIs.
184
+
185
+ Args:
186
+ mcp_token: The MCP token
187
+
188
+ Returns:
189
+ The provider token or None if not found
190
+ """
191
+ return self.provider_tokens.get(mcp_token) # Changed from self.storage to self
192
+
193
+
194
+ class GolfOAuthProvider(OAuthAuthorizationServerProvider):
195
+ """OAuth provider implementation for GolfMCP.
196
+
197
+ This class implements the OAuthAuthorizationServerProvider interface
198
+ for GolfMCP servers. It handles the OAuth 2.0 authentication flow,
199
+ token management, and client registration.
200
+ """
201
+
202
+ def __init__(self, config: ProviderConfig):
203
+ """Initialize the provider.
204
+
205
+ Args:
206
+ config: The provider configuration
207
+ """
208
+ self.config = config
209
+ self.storage = TokenStorage()
210
+ self.state_mapping: Dict[str, Dict[str, Any]] = {} # Initialize state_mapping
211
+
212
+ # Register default client
213
+ self._register_default_client()
214
+
215
+ def _get_client_id(self) -> str:
216
+ """Get the client ID from config or environment."""
217
+ if self.config.client_id:
218
+ return self.config.client_id
219
+
220
+ if self.config.client_id_env_var:
221
+ value = os.environ.get(self.config.client_id_env_var)
222
+ if value:
223
+ return value
224
+
225
+ return "missing-client-id"
226
+
227
+ def _get_client_secret(self) -> str:
228
+ """Get the client secret from config or environment."""
229
+ if self.config.client_secret:
230
+ return self.config.client_secret
231
+
232
+ if self.config.client_secret_env_var:
233
+ value = os.environ.get(self.config.client_secret_env_var)
234
+ if value:
235
+ return value
236
+
237
+ return "missing-client-secret"
238
+
239
+ def _get_jwt_secret(self) -> str:
240
+ """Get the JWT secret from config. It's expected to be resolved by server startup."""
241
+ if self.config.jwt_secret:
242
+ # _diag_logger.info(f"GolfOAuthProvider: Using JWT secret from config: {self.config.jwt_secret[:5]}...")
243
+ return self.config.jwt_secret
244
+ else:
245
+ raise ValueError("JWT Secret is not configured in the provider. Check server logs and environment variables.")
246
+
247
+ def _register_default_client(self) -> None:
248
+ """Register a default client for MCP."""
249
+ # These are the URIs where *this server* is allowed to redirect an MCP client
250
+ # after successful authentication and MCP auth code generation.
251
+ client_redirect_uris = [
252
+ # Common redirect URI for MCP Inspector running locally
253
+ "http://localhost:5173/callback",
254
+ "http://127.0.0.1:5173/callback",
255
+ # A generic callback relative to the server's issuer URL, if needed by some clients
256
+ # This assumes such a client-side endpoint exists.
257
+ f"{self.config.issuer_url.rstrip('/') if self.config.issuer_url else 'http://localhost:3000'}/client/callback"
258
+ ]
259
+
260
+ default_client = OAuthClientInformationFull(
261
+ client_id="default",
262
+ client_name="Default MCP Client",
263
+ client_secret="", # Public client
264
+ redirect_uris=client_redirect_uris,
265
+ grant_types=["authorization_code", "refresh_token"],
266
+ response_types=["code"],
267
+ token_endpoint_auth_method="none", # Public client
268
+ scope=" ".join(self.config.scopes)
269
+ )
270
+ self.storage.store_client("default", default_client)
271
+
272
+ def _generate_jwt(
273
+ self,
274
+ subject: str,
275
+ scopes: List[str],
276
+ expires_in: int = None
277
+ ) -> str:
278
+ """Generate a JWT token.
279
+
280
+ Args:
281
+ subject: The subject of the token (usually client_id)
282
+ scopes: The scopes granted to the token
283
+ expires_in: The token lifetime in seconds (or None for default)
284
+
285
+ Returns:
286
+ The signed JWT token
287
+ """
288
+ now = int(time.time())
289
+ expiry = now + (expires_in or self.config.token_expiration)
290
+
291
+ payload = {
292
+ "iss": self.config.issuer_url or "golf:auth",
293
+ "sub": subject,
294
+ "iat": now,
295
+ "exp": expiry,
296
+ "scp": scopes
297
+ }
298
+
299
+ jwt_secret = self._get_jwt_secret()
300
+ return jwt.encode(payload, jwt_secret, algorithm="HS256")
301
+
302
+ def _verify_jwt(self, token: str) -> Optional[Dict[str, Any]]:
303
+ """Verify a JWT token."""
304
+ jwt_secret = self._get_jwt_secret() # Get secret first
305
+ # _diag_logger.info(f"GolfOAuthProvider: _verify_jwt attempting to use secret: {jwt_secret[:5]}...")
306
+
307
+ try:
308
+ payload = jwt.decode(token, jwt_secret, algorithms=["HS256"], options={"verify_signature": True})
309
+
310
+ if payload.get("exp", 0) < time.time():
311
+ exp_timestamp = payload.get("exp")
312
+ current_timestamp = time.time()
313
+ exp_datetime_str = str(datetime.fromtimestamp(exp_timestamp)) if exp_timestamp is not None else "N/A"
314
+ current_datetime_str = str(datetime.fromtimestamp(current_timestamp))
315
+ return None
316
+ return payload
317
+ except jwt.ExpiredSignatureError as e:
318
+ return None
319
+ except jwt.PyJWTError as e:
320
+ return None
321
+ except Exception as e: # Catch any other unexpected error during decode
322
+ return None
323
+
324
+ async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
325
+ """Get client information by ID.
326
+
327
+ Args:
328
+ client_id: The client ID
329
+
330
+ Returns:
331
+ The client information or None if not found
332
+ """
333
+ return self.storage.get_client(client_id)
334
+
335
+ async def register_client(
336
+ self,
337
+ client_info: OAuthClientInformationFull
338
+ ) -> None:
339
+ """Register a new client."""
340
+ # Add detailed logging at the beginning
341
+ client_id_to_register = getattr(client_info, 'client_id', 'UNKNOWN (client_info has no client_id attribute)')
342
+ try:
343
+ # Validate the client information
344
+ if not client_info.client_id:
345
+ raise RegistrationError(
346
+ error="invalid_client_metadata",
347
+ error_description="Client ID is missing in client_info provided to register_client"
348
+ )
349
+
350
+ if not client_info.redirect_uris:
351
+ raise RegistrationError(
352
+ error="invalid_redirect_uri",
353
+ error_description="At least one redirect URI is required"
354
+ )
355
+
356
+ # Store the client
357
+ self.storage.store_client(client_info.client_id, client_info)
358
+ except Exception as e:
359
+ raise # Re-raise the exception so FastMCP can handle it
360
+
361
+ async def authorize(
362
+ self, client: OAuthClientInformationFull, params: AuthorizationParams # params from MCP client
363
+ ) -> str:
364
+ """Handle an authorization request.
365
+ This method is called when an MCP client requests authorization.
366
+ It should return a URL to redirect the user to the external IdP (e.g., GitHub).
367
+ """
368
+ import secrets
369
+ import urllib.parse
370
+
371
+ idp_flow_state = secrets.token_hex(16)
372
+ mcp_client_original_state = params.state
373
+
374
+ self.state_mapping[idp_flow_state] = {
375
+ "client_id": client.client_id,
376
+ "redirect_uri": str(params.redirect_uri),
377
+ "code_challenge": params.code_challenge,
378
+ "code_challenge_method": "S256" if params.code_challenge else None, # Store S256 if challenge exists, else None
379
+ "scopes": params.scopes,
380
+ "redirect_uri_provided_explicitly": params.redirect_uri_provided_explicitly,
381
+ "mcp_client_original_state": mcp_client_original_state
382
+ }
383
+
384
+ # Use self.config.callback_path for consistency
385
+ idp_callback_uri = f"{self.config.issuer_url.rstrip('/')}{self.config.callback_path}"
386
+
387
+ client_id = self._get_client_id()
388
+
389
+ auth_params_for_idp = {
390
+ "client_id": client_id,
391
+ "redirect_uri": idp_callback_uri,
392
+ "scope": " ".join(self.config.scopes),
393
+ "state": idp_flow_state,
394
+ "response_type": "code"
395
+ }
396
+
397
+ if params.code_challenge:
398
+ auth_params_for_idp["code_challenge"] = params.code_challenge
399
+ # Always use S256 if a challenge is present, as it's the standard and what the client sends.
400
+ auth_params_for_idp["code_challenge_method"] = "S256"
401
+
402
+ query_for_idp = urllib.parse.urlencode(auth_params_for_idp)
403
+
404
+ return f"{self.config.authorize_url}?{query_for_idp}"
405
+
406
+ async def load_authorization_code(
407
+ self,
408
+ client: OAuthClientInformationFull,
409
+ code: str
410
+ ) -> Optional[AuthorizationCode]:
411
+ """Load an authorization code.
412
+
413
+ Args:
414
+ client: The client information
415
+ code: The authorization code
416
+
417
+ Returns:
418
+ The authorization code object or None if not found
419
+ """
420
+ auth_code = self.storage.get_auth_code(code)
421
+
422
+ if not auth_code:
423
+ return None
424
+
425
+ # Verify the code belongs to this client
426
+ if auth_code.client_id != client.client_id:
427
+ return None
428
+
429
+ # Verify the code hasn't expired
430
+ if auth_code.expires_at and auth_code.expires_at < datetime.now().timestamp():
431
+ self.storage.delete_auth_code(code)
432
+ return None
433
+
434
+ return auth_code
435
+
436
+ async def exchange_authorization_code(
437
+ self,
438
+ client: OAuthClientInformationFull,
439
+ code: AuthorizationCode # This is AuthorizationCode object
440
+ ) -> OAuthToken:
441
+ """Exchange an authorization code for tokens.
442
+
443
+ Args:
444
+ client: The client information
445
+ code: The authorization code object
446
+
447
+ Returns:
448
+ The OAuth token response
449
+
450
+ Raises:
451
+ TokenError: If the code exchange fails
452
+ """
453
+ # Retrieve the provider token that was stored temporarily during callback
454
+ provider_token = self.storage.get_provider_token_for_auth_code(code.code)
455
+
456
+ # Delete the code and its mapping to ensure one-time use
457
+ self.storage.delete_auth_code(code.code) # This now also deletes the mapping
458
+
459
+ # Generate an access token
460
+ access_token_str = self._generate_jwt( # Renamed for clarity
461
+ subject=client.client_id,
462
+ scopes=code.scopes
463
+ )
464
+
465
+ # Generate a refresh token if needed
466
+ refresh_token_str = str(uuid.uuid4()) if "refresh_token" in client.grant_types else None # Renamed for clarity
467
+
468
+ # Store the mapping from our new MCP access token to the provider's access token
469
+ if provider_token and access_token_str:
470
+ self.storage.store_provider_token(access_token_str, provider_token)
471
+
472
+ # Store the tokens
473
+ if refresh_token_str:
474
+ self.storage.store_refresh_token(
475
+ refresh_token_str,
476
+ RefreshToken(
477
+ token=refresh_token_str,
478
+ client_id=client.client_id,
479
+ scopes=code.scopes,
480
+ expires_at=int(datetime.now().timestamp() + (self.config.token_expiration * 24)) # 24x longer, cast to int
481
+ )
482
+ )
483
+
484
+ # Store access token information for validation later
485
+ # Note: For JWTs, we might not need to store them if we can verify the signature
486
+ self.storage.store_access_token(
487
+ access_token_str,
488
+ AccessToken(
489
+ token=access_token_str,
490
+ client_id=client.client_id,
491
+ scopes=code.scopes,
492
+ expires_at=int(datetime.now().timestamp() + self.config.token_expiration) # Cast to int
493
+ )
494
+ )
495
+
496
+ # Create and return the OAuth token response
497
+ return OAuthToken(
498
+ access_token=access_token_str,
499
+ token_type="bearer",
500
+ expires_in=self.config.token_expiration,
501
+ refresh_token=refresh_token_str,
502
+ scope=" ".join(code.scopes)
503
+ )
504
+
505
+ async def load_refresh_token(
506
+ self,
507
+ client: OAuthClientInformationFull,
508
+ refresh_token: str
509
+ ) -> Optional[RefreshToken]:
510
+ """Load a refresh token.
511
+
512
+ Args:
513
+ client: The client information
514
+ refresh_token: The refresh token string
515
+
516
+ Returns:
517
+ The refresh token object or None if not found
518
+ """
519
+ token = self.storage.get_refresh_token(refresh_token)
520
+
521
+ if not token:
522
+ return None
523
+
524
+ # Verify the token belongs to this client
525
+ if token.client_id != client.client_id:
526
+ return None
527
+
528
+ # Verify the token hasn't expired
529
+ if token.expires_at and token.expires_at < datetime.now().timestamp():
530
+ self.storage.delete_refresh_token(refresh_token)
531
+ return None
532
+
533
+ return token
534
+
535
+ async def exchange_refresh_token(
536
+ self,
537
+ client: OAuthClientInformationFull,
538
+ refresh_token: RefreshToken,
539
+ scopes: List[str]
540
+ ) -> OAuthToken:
541
+ """Exchange a refresh token for a new token pair.
542
+
543
+ Args:
544
+ client: The client information
545
+ refresh_token: The refresh token object
546
+ scopes: The requested scopes (may be a subset of original)
547
+
548
+ Returns:
549
+ The new OAuth token response
550
+
551
+ Raises:
552
+ TokenError: If the token exchange fails
553
+ """
554
+ # Delete the old refresh token (implement token rotation for security)
555
+ self.storage.delete_refresh_token(refresh_token.token)
556
+
557
+ # Determine the scopes for the new token
558
+ # If requested scopes are provided, they must be a subset of the original
559
+ if scopes:
560
+ valid_scopes = [s for s in scopes if s in refresh_token.scopes]
561
+ if not valid_scopes:
562
+ valid_scopes = refresh_token.scopes
563
+ else:
564
+ valid_scopes = refresh_token.scopes
565
+
566
+ # Generate a new access token
567
+ access_token = self._generate_jwt(
568
+ subject=client.client_id,
569
+ scopes=valid_scopes
570
+ )
571
+
572
+ # Generate a new refresh token
573
+ new_refresh_token = str(uuid.uuid4())
574
+
575
+ # Find the provider token if it exists from the old access token
576
+ # Note: This assumes each refresh generates only one access token
577
+ old_access_tokens = [
578
+ token for token, data in self.storage.access_tokens.items()
579
+ if data.client_id == client.client_id
580
+ ]
581
+ provider_token = None
582
+ for old_token in old_access_tokens:
583
+ provider_token = self.storage.get_provider_token(old_token)
584
+ if provider_token:
585
+ # Store the provider token mapping for the new access token
586
+ self.storage.store_provider_token(access_token, provider_token)
587
+ break
588
+
589
+ # Store the new tokens
590
+ self.storage.store_refresh_token(
591
+ new_refresh_token,
592
+ RefreshToken(
593
+ token=new_refresh_token,
594
+ client_id=client.client_id,
595
+ scopes=valid_scopes,
596
+ expires_at=int(datetime.now().timestamp() + (self.config.token_expiration * 24)) # Cast to int
597
+ )
598
+ )
599
+
600
+ # Store access token information
601
+ self.storage.store_access_token(
602
+ access_token,
603
+ AccessToken(
604
+ token=access_token,
605
+ client_id=client.client_id,
606
+ scopes=valid_scopes,
607
+ expires_at=int(datetime.now().timestamp() + self.config.token_expiration) # Cast to int
608
+ )
609
+ )
610
+
611
+ # Create and return the OAuth token response
612
+ return OAuthToken(
613
+ access_token=access_token,
614
+ token_type="bearer",
615
+ expires_in=self.config.token_expiration,
616
+ refresh_token=new_refresh_token,
617
+ scope=" ".join(valid_scopes)
618
+ )
619
+
620
+ async def load_access_token(self, token: str) -> Optional[AccessToken]:
621
+ """Load and validate an access token."""
622
+
623
+ payload = self._verify_jwt(token)
624
+ if not payload:
625
+ return None
626
+
627
+ client_id = payload.get("sub")
628
+ scopes = payload.get("scp", [])
629
+ expires_at = payload.get("exp")
630
+
631
+
632
+ access_token_obj = AccessToken(
633
+ token=token,
634
+ client_id=client_id,
635
+ scopes=scopes,
636
+ expires_at=int(expires_at) if expires_at is not None else None
637
+ )
638
+
639
+ return access_token_obj
640
+
641
+ async def revoke_token(self, token: Union[AccessToken, RefreshToken]) -> None:
642
+ """Revoke a token.
643
+
644
+ Args:
645
+ token: The token to revoke (access or refresh)
646
+ """
647
+ # Try to revoke as access token
648
+ self.storage.delete_access_token(token.token)
649
+
650
+ # Try to revoke as refresh token
651
+ self.storage.delete_refresh_token(token.token)
652
+
653
+ # Clean up provider token mapping if it exists
654
+ provider_token = self.storage.get_provider_token(token.token)
655
+ if provider_token:
656
+ self.storage.provider_tokens.pop(token.token, None)
657
+
658
+ def get_provider_token(self, mcp_token: str) -> Optional[str]:
659
+ """Get the provider token associated with an MCP token.
660
+
661
+ This is a non-standard method to allow access to the provider token
662
+ (e.g., GitHub token) for a given MCP token. This can be used by
663
+ tools that need to access provider APIs.
664
+
665
+ Args:
666
+ mcp_token: The MCP token
667
+
668
+ Returns:
669
+ The provider token or None if not found
670
+ """
671
+ return self.storage.get_provider_token(mcp_token)
672
+
673
+
674
+ def create_callback_handler(provider: GolfOAuthProvider):
675
+ """Create a callback handler for OAuth authorization.
676
+
677
+ This function creates a callback handler that can be used to handle
678
+ the OAuth callback from the provider (e.g., GitHub).
679
+
680
+ Args:
681
+ provider: The OAuth provider
682
+
683
+ Returns:
684
+ An async function that handles the callback
685
+ """
686
+ async def handle_callback(request):
687
+ """Handle the OAuth callback.
688
+
689
+ Args:
690
+ request: The HTTP request
691
+
692
+ Returns:
693
+ The HTTP response
694
+ """
695
+ # Extract the code and state from the request
696
+ idp_auth_code = request.query_params.get("code") # Renamed for clarity: code from IdP
697
+ idp_state = request.query_params.get("state") # Renamed for clarity: state from IdP
698
+
699
+ if not idp_auth_code:
700
+ return RedirectResponse("/auth-error?error=no_code_from_idp") # More specific error
701
+
702
+ # Use provider.config.callback_path for consistency
703
+ # This is the redirect_uri registered with the IdP and used in the /authorize step
704
+ idp_callback_uri_for_token_exchange = f"{provider.config.issuer_url.rstrip('/')}{provider.config.callback_path}"
705
+
706
+ client_id_for_idp = provider._get_client_id()
707
+ client_secret_for_idp = provider._get_client_secret()
708
+
709
+ async with httpx.AsyncClient() as client:
710
+ response = await client.post(
711
+ provider.config.token_url,
712
+ headers={"Accept": "application/json"},
713
+ data={
714
+ "client_id": client_id_for_idp,
715
+ "client_secret": client_secret_for_idp,
716
+ "code": idp_auth_code, # Use code from IdP
717
+ "redirect_uri": idp_callback_uri_for_token_exchange
718
+ }
719
+ )
720
+
721
+ if response.status_code != 200:
722
+ error_detail = response.text[:200] # Limit error detail length
723
+ return RedirectResponse(f"/auth-error?error=idp_token_exchange_failed&detail={urllib.parse.quote(error_detail)}")
724
+
725
+ # Get the provider token from the response
726
+ token_data = response.json()
727
+ provider_access_token = token_data.get("access_token") # This is the token from GitHub/Google etc.
728
+
729
+ if not provider_access_token:
730
+ return RedirectResponse("/auth-error?error=no_access_token_from_idp")
731
+
732
+ try:
733
+ # Get user information from the provider using the token (optional step)
734
+ # user_info = None (keep this if user_info is used later, otherwise remove)
735
+ # ... (userinfo fetching logic if needed) ...
736
+
737
+ original_mcp_client_details = provider.state_mapping.pop(idp_state, None) # Use state from IdP
738
+ if not original_mcp_client_details:
739
+ return RedirectResponse(f"/auth-error?error=invalid_idp_state")
740
+
741
+ original_mcp_client_id = original_mcp_client_details["client_id"]
742
+ original_mcp_redirect_uri = original_mcp_client_details["redirect_uri"] # MCP client's redirect_uri
743
+ original_code_challenge = original_mcp_client_details["code_challenge"]
744
+ original_code_challenge_method = original_mcp_client_details["code_challenge_method"]
745
+
746
+ requested_scopes_for_mcp_server_str = original_mcp_client_details["scopes"]
747
+ mcp_client_original_state_to_pass_back = original_mcp_client_details.get("mcp_client_original_state")
748
+ original_redirect_uri_provided_explicitly = original_mcp_client_details["redirect_uri_provided_explicitly"]
749
+
750
+ mcp_client = await provider.get_client(original_mcp_client_id) # Renamed for clarity
751
+ if not mcp_client:
752
+ return RedirectResponse(f"/auth-error?error=mcp_client_not_found_post_callback")
753
+
754
+ final_scopes_for_mcp_auth_code: List[str]
755
+ if requested_scopes_for_mcp_server_str: # Scopes requested by MCP client
756
+ final_scopes_for_mcp_auth_code = requested_scopes_for_mcp_server_str.split()
757
+ else: # Default to client's registered scopes if none explicitly requested
758
+ final_scopes_for_mcp_auth_code = mcp_client.scope.split() if mcp_client.scope else []
759
+
760
+ # This is the auth code our GolfMCP server issues to the MCP client
761
+ mcp_auth_code_str = str(uuid.uuid4())
762
+
763
+ # Store the mapping from our mcp_auth_code_str to the provider_access_token (e.g., GitHub token)
764
+ # This will be retrieved when the MCP client exchanges mcp_auth_code_str for an MCP access token
765
+ provider.storage.store_auth_code_provider_token_mapping(mcp_auth_code_str, provider_access_token)
766
+
767
+ # Create the AuthorizationCode object for our server
768
+ mcp_auth_code_obj = AuthorizationCode( # Renamed for clarity
769
+ code=mcp_auth_code_str,
770
+ client_id=mcp_client.client_id,
771
+ redirect_uri=original_mcp_redirect_uri,
772
+ scopes=final_scopes_for_mcp_auth_code,
773
+ expires_at=int(datetime.now().timestamp() + 600), # 10 minutes, cast to int
774
+ redirect_uri_provided_explicitly=original_redirect_uri_provided_explicitly,
775
+ code_challenge=original_code_challenge,
776
+ code_challenge_method=original_code_challenge_method
777
+ )
778
+
779
+ # Store our auth code object (without provider_token as an attribute)
780
+ provider.storage.store_auth_code(mcp_auth_code_str, mcp_auth_code_obj)
781
+
782
+ query_params_for_mcp_client = {
783
+ "code": mcp_auth_code_str # Send our generated auth code to the MCP client
784
+ }
785
+ if mcp_client_original_state_to_pass_back:
786
+ query_params_for_mcp_client["state"] = mcp_client_original_state_to_pass_back
787
+
788
+ import urllib.parse # Ensure it's imported here too
789
+ final_query_for_mcp_client = urllib.parse.urlencode(query_params_for_mcp_client)
790
+ final_redirect_to_mcp_client = f"{original_mcp_redirect_uri}?{final_query_for_mcp_client}"
791
+
792
+ return RedirectResponse(final_redirect_to_mcp_client)
793
+
794
+ except Exception as e:
795
+ # Avoid sending raw exception details to the client for security
796
+ return RedirectResponse("/auth-error?error=callback_processing_failed&detail=internal_server_error")
797
+
798
+ return handle_callback