gopher-mcp-python 0.1.2.1__tar.gz → 0.1.15__tar.gz

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.
Files changed (37) hide show
  1. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/PKG-INFO +1 -1
  2. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/__init__.py +7 -2
  3. gopher_mcp_python-0.1.15/gopher_mcp_python/auth/__init__.py +31 -0
  4. gopher_mcp_python-0.1.15/gopher_mcp_python/auth/errors.py +48 -0
  5. gopher_mcp_python-0.1.15/gopher_mcp_python/auth/gopher_auth.py +255 -0
  6. gopher_mcp_python-0.1.15/gopher_mcp_python/auth/scope_helpers.py +34 -0
  7. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/__init__.py +12 -0
  8. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/auth_client.py +54 -0
  9. gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/auto_refresh.py +89 -0
  10. gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/config_loader.py +255 -0
  11. gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/loader.py +854 -0
  12. gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/oauth_client.py +232 -0
  13. gopher_mcp_python-0.1.15/gopher_mcp_python/ffi/auth/session_manager.py +146 -0
  14. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/types.py +11 -1
  15. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/library.py +12 -8
  16. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/PKG-INFO +1 -1
  17. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/SOURCES.txt +9 -0
  18. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/pyproject.toml +1 -1
  19. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_ffi.py +23 -18
  20. gopher_mcp_python-0.1.15/tests/test_gopher_auth.py +154 -0
  21. gopher_mcp_python-0.1.2.1/gopher_mcp_python/ffi/auth/loader.py +0 -404
  22. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/LICENSE +0 -0
  23. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/README.md +0 -0
  24. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/agent.py +0 -0
  25. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/config.py +0 -0
  26. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/errors.py +0 -0
  27. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/__init__.py +0 -0
  28. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/ffi/auth/validation_options.py +0 -0
  29. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/result.py +0 -0
  30. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python/server_config.py +0 -0
  31. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/dependency_links.txt +0 -0
  32. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/requires.txt +0 -0
  33. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/gopher_mcp_python.egg-info/top_level.txt +0 -0
  34. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/setup.cfg +0 -0
  35. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/setup.py +0 -0
  36. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_config.py +0 -0
  37. {gopher_mcp_python-0.1.2.1 → gopher_mcp_python-0.1.15}/tests/test_result.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gopher-mcp-python
3
- Version: 0.1.2.1
3
+ Version: 0.1.15
4
4
  Summary: Python SDK for Gopher MCP - AI Agent orchestration framework with native performance
5
5
  Author-email: Gopher Security <dev@gophersecurity.com>
6
6
  License: Apache-2.0
@@ -25,7 +25,12 @@ Example:
25
25
  from gopher_mcp_python.agent import GopherAgent
26
26
  from gopher_mcp_python.config import GopherAgentConfig, GopherAgentConfigBuilder
27
27
  from gopher_mcp_python.result import AgentResult, AgentResultStatus, AgentResultBuilder
28
- from gopher_mcp_python.errors import AgentError, ApiKeyError, ConnectionError, TimeoutError
28
+ from gopher_mcp_python.errors import (
29
+ AgentError,
30
+ ApiKeyError,
31
+ ConnectionError,
32
+ TimeoutError,
33
+ )
29
34
  from gopher_mcp_python.server_config import ServerConfig
30
35
  from gopher_mcp_python.ffi import GopherOrchLibrary
31
36
 
@@ -45,7 +50,7 @@ from gopher_mcp_python.ffi.auth import (
45
50
  is_auth_available,
46
51
  )
47
52
 
48
- __version__ = "0.1.2"
53
+ __version__ = "0.1.15"
49
54
 
50
55
  __all__ = [
51
56
  # Main classes
@@ -0,0 +1,31 @@
1
+ """
2
+ Auth Module - Reusable OAuth/JWT authentication for Python MCP servers.
3
+ """
4
+
5
+ from gopher_mcp_python.auth.gopher_auth import GopherAuth
6
+ from gopher_mcp_python.auth.errors import (
7
+ GopherAuthError,
8
+ TokenValidationError,
9
+ InsufficientScopesError,
10
+ JwksError,
11
+ ConfigurationError,
12
+ TokenExchangeError,
13
+ )
14
+ from gopher_mcp_python.auth.scope_helpers import (
15
+ has_scope,
16
+ has_all_scopes,
17
+ has_any_scope,
18
+ )
19
+
20
+ __all__ = [
21
+ "GopherAuth",
22
+ "GopherAuthError",
23
+ "TokenValidationError",
24
+ "InsufficientScopesError",
25
+ "JwksError",
26
+ "ConfigurationError",
27
+ "TokenExchangeError",
28
+ "has_scope",
29
+ "has_all_scopes",
30
+ "has_any_scope",
31
+ ]
@@ -0,0 +1,48 @@
1
+ """Custom error classes for the auth module."""
2
+
3
+
4
+ class GopherAuthError(Exception):
5
+ """Base error for auth operations."""
6
+
7
+ pass
8
+
9
+
10
+ class TokenValidationError(GopherAuthError):
11
+ """Token validation failed."""
12
+
13
+ def __init__(self, message: str, error_code: int = 0):
14
+ super().__init__(message)
15
+ self.error_code = error_code
16
+
17
+
18
+ class InsufficientScopesError(GopherAuthError):
19
+ """Required scopes not present."""
20
+
21
+ def __init__(self, required_scopes: list, actual_scopes: list, message: str = ""):
22
+ msg = message or (
23
+ f"Insufficient scopes: required {required_scopes}, actual {actual_scopes}"
24
+ )
25
+ super().__init__(msg)
26
+ self.required_scopes = required_scopes
27
+ self.actual_scopes = actual_scopes
28
+
29
+
30
+ class JwksError(GopherAuthError):
31
+ """JWKS fetch or parsing failed."""
32
+
33
+ pass
34
+
35
+
36
+ class ConfigurationError(GopherAuthError):
37
+ """Invalid configuration."""
38
+
39
+ pass
40
+
41
+
42
+ class TokenExchangeError(GopherAuthError):
43
+ """Token exchange failed."""
44
+
45
+ def __init__(self, message: str, error_code: str = "", error_description: str = ""):
46
+ super().__init__(message)
47
+ self.error_code = error_code
48
+ self.error_description = error_description
@@ -0,0 +1,255 @@
1
+ """
2
+ GopherAuth - Reusable auth module for Python.
3
+
4
+ Provides OAuth 2.0 / JWT authentication via native FFI bindings.
5
+ Matches the JS SDK's GopherAuth class API surface.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from gopher_mcp_python.ffi.auth.auth_client import (
11
+ GopherAuthClient,
12
+ gopher_init_auth_library,
13
+ gopher_shutdown_auth_library,
14
+ gopher_generate_www_authenticate_header_v2,
15
+ )
16
+ from gopher_mcp_python.ffi.auth.config_loader import GopherAuthConfig
17
+ from gopher_mcp_python.ffi.auth.oauth_client import GopherOAuthClient, TokenResponse
18
+ from gopher_mcp_python.ffi.auth.session_manager import GopherSessionManager
19
+ from gopher_mcp_python.ffi.auth.loader import (
20
+ gopher_auth_build_protected_resource_metadata,
21
+ )
22
+ from gopher_mcp_python.ffi.auth.types import TokenPayload, GopherAuthContext
23
+
24
+ from gopher_mcp_python.auth.errors import (
25
+ ConfigurationError,
26
+ TokenValidationError,
27
+ InsufficientScopesError,
28
+ TokenExchangeError,
29
+ )
30
+ from gopher_mcp_python.auth.scope_helpers import (
31
+ has_scope,
32
+ has_all_scopes,
33
+ has_any_scope,
34
+ )
35
+
36
+
37
+ class GopherAuth:
38
+ """Reusable auth module wrapping native FFI bindings."""
39
+
40
+ def __init__(
41
+ self,
42
+ config_path: Optional[str] = None,
43
+ config: Optional[Dict[str, Any]] = None,
44
+ auth_disabled: bool = False,
45
+ ) -> None:
46
+ self._config_path = config_path
47
+ self._config_dict = config
48
+ self._auth_disabled = auth_disabled
49
+ self._config: Optional[GopherAuthConfig] = None
50
+ self._auth_client: Optional[GopherAuthClient] = None
51
+ self._oauth_client: Optional[GopherOAuthClient] = None
52
+ self._session_manager: Optional[GopherSessionManager] = None
53
+ self._initialized = False
54
+
55
+ def initialize(self) -> None:
56
+ """Initialize the auth module. Must be called before any other method."""
57
+ if self._initialized:
58
+ return
59
+
60
+ if self._auth_disabled:
61
+ self._initialized = True
62
+ return
63
+
64
+ gopher_init_auth_library()
65
+
66
+ if self._config_path:
67
+ self._config = GopherAuthConfig.load_file(self._config_path)
68
+ elif self._config_dict:
69
+ pairs: Dict[str, str] = {}
70
+ mapping = {
71
+ "auth_server_url": "auth_server_url",
72
+ "client_id": "client_id",
73
+ "client_secret": "client_secret",
74
+ "audience": "audience",
75
+ "issuer": "issuer",
76
+ "jwks_uri": "jwks_uri",
77
+ "oauth_authorize_url": "oauth_authorize_url",
78
+ "oauth_token_url": "oauth_token_url",
79
+ "server_url": "server_url",
80
+ "allowed_scopes": "allowed_scopes",
81
+ "exchange_idps": "exchange_idps",
82
+ "host": "host",
83
+ "port": "port",
84
+ }
85
+ for py_key, cfg_key in mapping.items():
86
+ val = self._config_dict.get(py_key)
87
+ if val is not None:
88
+ pairs[cfg_key] = str(val)
89
+ self._config = GopherAuthConfig.load_from_pairs(pairs)
90
+ else:
91
+ raise ConfigurationError("Either config_path or config must be provided")
92
+
93
+ # Create auth client
94
+ jwks_uri = self._config.get_string("jwks_uri")
95
+ issuer = self._config.get_string("issuer")
96
+ if jwks_uri and issuer:
97
+ self._auth_client = GopherAuthClient(jwks_uri, issuer)
98
+ cache_dur = self._config.get_string("jwks_cache_duration")
99
+ if cache_dur:
100
+ self._auth_client.set_option("cache_duration", cache_dur)
101
+
102
+ # Create OAuth client
103
+ token_endpoint = self._config.get_string("token_endpoint")
104
+ client_id = self._config.get_string("client_id")
105
+ client_secret = self._config.get_string("client_secret")
106
+ if token_endpoint and client_id:
107
+ self._oauth_client = GopherOAuthClient(
108
+ token_endpoint, client_id, client_secret or None, 30
109
+ )
110
+
111
+ self._session_manager = GopherSessionManager(300)
112
+ self._initialized = True
113
+
114
+ def shutdown(self) -> None:
115
+ """Shutdown and release all native resources."""
116
+ if self._session_manager:
117
+ self._session_manager.destroy()
118
+ if self._oauth_client:
119
+ self._oauth_client.destroy()
120
+ if self._auth_client:
121
+ self._auth_client.destroy()
122
+ if self._config:
123
+ self._config.destroy()
124
+ self._session_manager = None
125
+ self._oauth_client = None
126
+ self._auth_client = None
127
+ self._config = None
128
+ if self._initialized and not self._auth_disabled:
129
+ gopher_shutdown_auth_library()
130
+ self._initialized = False
131
+
132
+ def validate_token(
133
+ self,
134
+ token: str,
135
+ required_scopes: Optional[List[str]] = None,
136
+ ) -> TokenPayload:
137
+ """Validate a JWT token. Raises on failure."""
138
+ self._ensure_initialized()
139
+ if not self._auth_client:
140
+ raise TokenValidationError("Auth client not initialized")
141
+
142
+ result = self._auth_client.validate_token(token)
143
+ if not result or not result.valid:
144
+ raise TokenValidationError(
145
+ result.error_message if result else "Token validation failed",
146
+ result.error_code if result else 0,
147
+ )
148
+
149
+ payload = self._auth_client.extract_payload(token)
150
+ if not payload:
151
+ raise TokenValidationError("Failed to extract payload")
152
+
153
+ if required_scopes:
154
+ token_scopes = [s for s in payload.scopes.split(" ") if s]
155
+ missing = [s for s in required_scopes if s not in token_scopes]
156
+ if missing:
157
+ raise InsufficientScopesError(required_scopes, token_scopes)
158
+
159
+ return payload
160
+
161
+ def has_scope(self, payload: TokenPayload, scope: str) -> bool:
162
+ ctx = GopherAuthContext(
163
+ user_id=payload.subject,
164
+ scopes=payload.scopes,
165
+ audience=payload.audience or "",
166
+ token_expiry=payload.expiration or 0,
167
+ authenticated=True,
168
+ )
169
+ return has_scope(ctx, scope)
170
+
171
+ def has_all_scopes(self, payload: TokenPayload, scopes: List[str]) -> bool:
172
+ ctx = GopherAuthContext(
173
+ user_id=payload.subject,
174
+ scopes=payload.scopes,
175
+ audience=payload.audience or "",
176
+ token_expiry=payload.expiration or 0,
177
+ authenticated=True,
178
+ )
179
+ return has_all_scopes(ctx, scopes)
180
+
181
+ def has_any_scope(self, payload: TokenPayload, scopes: List[str]) -> bool:
182
+ ctx = GopherAuthContext(
183
+ user_id=payload.subject,
184
+ scopes=payload.scopes,
185
+ audience=payload.audience or "",
186
+ token_expiry=payload.expiration or 0,
187
+ authenticated=True,
188
+ )
189
+ return has_any_scope(ctx, scopes)
190
+
191
+ def exchange_token(
192
+ self,
193
+ subject_token: str,
194
+ requested_issuer: str,
195
+ audience: Optional[str] = None,
196
+ scope: Optional[str] = None,
197
+ ) -> TokenResponse:
198
+ """Exchange a token (RFC 8693). Raises on failure."""
199
+ self._ensure_initialized()
200
+ if not self._oauth_client:
201
+ raise TokenExchangeError("OAuth client not initialized")
202
+ result = self._oauth_client.token_exchange(
203
+ subject_token, requested_issuer, audience, scope
204
+ )
205
+ if not result.success:
206
+ raise TokenExchangeError(
207
+ result.error or "Token exchange failed",
208
+ result.error or "",
209
+ )
210
+ return result
211
+
212
+ def get_protected_resource_metadata(self) -> dict:
213
+ server_url = self._config.get_string("server_url") if self._config else ""
214
+ scopes = self._config.get_string("allowed_scopes") if self._config else ""
215
+ return gopher_auth_build_protected_resource_metadata(
216
+ f"{server_url}/mcp", server_url, scopes or None
217
+ )
218
+
219
+ def get_www_authenticate_header(
220
+ self,
221
+ error: str = "",
222
+ error_description: str = "",
223
+ ) -> str:
224
+ server_url = self._config.get_string("server_url") if self._config else ""
225
+ resource_metadata = f"{server_url}/.well-known/oauth-protected-resource"
226
+ return (
227
+ gopher_generate_www_authenticate_header_v2(
228
+ server_url, resource_metadata, "", error, error_description
229
+ )
230
+ or "Bearer"
231
+ )
232
+
233
+ def get_token_endpoint(self) -> str:
234
+ return self._config.get_string("token_endpoint") if self._config else ""
235
+
236
+ @property
237
+ def native_config(self) -> Optional[GopherAuthConfig]:
238
+ return self._config
239
+
240
+ @property
241
+ def is_disabled(self) -> bool:
242
+ return self._auth_disabled
243
+
244
+ def __enter__(self) -> "GopherAuth":
245
+ self.initialize()
246
+ return self
247
+
248
+ def __exit__(self, *args: object) -> None:
249
+ self.shutdown()
250
+
251
+ def _ensure_initialized(self) -> None:
252
+ if not self._initialized:
253
+ raise ConfigurationError(
254
+ "GopherAuth not initialized. Call initialize() first."
255
+ )
@@ -0,0 +1,34 @@
1
+ """Scope validation helpers for per-request usage."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from gopher_mcp_python.ffi.auth.types import GopherAuthContext
6
+ from gopher_mcp_python.ffi.auth.loader import (
7
+ gopher_auth_validate_all_scopes,
8
+ gopher_auth_validate_any_scopes,
9
+ )
10
+
11
+
12
+ def has_scope(context: Optional[GopherAuthContext], scope: str) -> bool:
13
+ """Check if context has a specific scope."""
14
+ if not context or not context.scopes:
15
+ return False
16
+ return gopher_auth_validate_all_scopes(context.scopes, scope)
17
+
18
+
19
+ def has_all_scopes(context: Optional[GopherAuthContext], scopes: List[str]) -> bool:
20
+ """Check if context has ALL required scopes (AND logic)."""
21
+ if not context or not context.scopes:
22
+ return len(scopes) == 0
23
+ if not scopes:
24
+ return True
25
+ return gopher_auth_validate_all_scopes(context.scopes, " ".join(scopes))
26
+
27
+
28
+ def has_any_scope(context: Optional[GopherAuthContext], scopes: List[str]) -> bool:
29
+ """Check if context has ANY of the required scopes (OR logic)."""
30
+ if not context or not context.scopes:
31
+ return len(scopes) == 0
32
+ if not scopes:
33
+ return True
34
+ return gopher_auth_validate_any_scopes(context.scopes, " ".join(scopes))
@@ -33,6 +33,16 @@ from gopher_mcp_python.ffi.auth.validation_options import (
33
33
  gopher_create_validation_options,
34
34
  )
35
35
 
36
+ from gopher_mcp_python.ffi.auth.config_loader import (
37
+ GopherAuthConfig,
38
+ )
39
+
40
+ from gopher_mcp_python.ffi.auth.oauth_client import (
41
+ GopherOAuthClient,
42
+ TokenResponse,
43
+ RegistrationResponse,
44
+ )
45
+
36
46
  from gopher_mcp_python.ffi.auth.auth_client import (
37
47
  GopherAuthClient,
38
48
  gopher_init_auth_library,
@@ -71,6 +81,8 @@ __all__ = [
71
81
  # Validation options
72
82
  "GopherValidationOptions",
73
83
  "gopher_create_validation_options",
84
+ # Config loader
85
+ "GopherAuthConfig",
74
86
  # Auth client
75
87
  "GopherAuthClient",
76
88
  "gopher_init_auth_library",
@@ -423,12 +423,22 @@ class GopherAuthClient:
423
423
  if get_exp(payload_handle, byref(exp_value)) == GopherAuthError.SUCCESS:
424
424
  expiration = exp_value.value
425
425
 
426
+ # Extract extended claims via payload_get_claim
427
+ email = self._get_payload_claim(payload_handle, "email")
428
+ name_val = self._get_payload_claim(payload_handle, "name")
429
+ organization_id = self._get_payload_claim(payload_handle, "organization_id")
430
+ server_id = self._get_payload_claim(payload_handle, "server_id")
431
+
426
432
  return TokenPayload(
427
433
  subject=subject or "",
428
434
  scopes=scopes or "",
429
435
  audience=audience,
430
436
  expiration=expiration,
431
437
  issuer=issuer,
438
+ email=email,
439
+ name=name_val,
440
+ organization_id=organization_id,
441
+ server_id=server_id,
432
442
  )
433
443
  finally:
434
444
  # Clean up payload handle
@@ -472,6 +482,45 @@ class GopherAuthClient:
472
482
 
473
483
  return None
474
484
 
485
+ def _get_payload_claim(
486
+ self,
487
+ payload_handle: c_void_p,
488
+ claim_name: str,
489
+ ) -> Optional[str]:
490
+ """
491
+ Get a custom claim from payload by name.
492
+
493
+ Args:
494
+ payload_handle: The payload handle.
495
+ claim_name: The claim name to extract.
496
+
497
+ Returns:
498
+ The claim value, or None if not present.
499
+ """
500
+ funcs = get_auth_functions()
501
+ get_claim = funcs.get("payload_get_claim")
502
+ if get_claim is None:
503
+ return None
504
+
505
+ value_out = c_char_p()
506
+ result = get_claim(
507
+ payload_handle,
508
+ claim_name.encode("utf-8"),
509
+ byref(value_out),
510
+ )
511
+
512
+ if result != GopherAuthError.SUCCESS:
513
+ return None
514
+
515
+ if value_out.value:
516
+ value = value_out.value.decode("utf-8")
517
+ free_string = funcs.get("free_string")
518
+ if free_string:
519
+ free_string(value_out)
520
+ return value
521
+
522
+ return None
523
+
475
524
  def validate_and_extract(
476
525
  self,
477
526
  token: str,
@@ -515,6 +564,11 @@ class GopherAuthClient:
515
564
  self._handle = None
516
565
  self._destroyed = True
517
566
 
567
+ def get_handle(self) -> c_void_p:
568
+ """Get the native handle (for internal use by auto-refresh)."""
569
+ self._ensure_not_destroyed()
570
+ return self._handle
571
+
518
572
  def is_destroyed(self) -> bool:
519
573
  """
520
574
  Check if this client has been destroyed.
@@ -0,0 +1,89 @@
1
+ """
2
+ Auto-Refresh - Combines session lookup, token refresh, and re-validation.
3
+ """
4
+
5
+ from ctypes import POINTER, byref, c_char_p
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from gopher_mcp_python.ffi.auth.loader import (
10
+ GopherAuthValidationResult,
11
+ get_auth_functions,
12
+ )
13
+ from gopher_mcp_python.ffi.auth.auth_client import GopherAuthClient
14
+ from gopher_mcp_python.ffi.auth.oauth_client import GopherOAuthClient
15
+ from gopher_mcp_python.ffi.auth.session_manager import GopherSessionManager
16
+ from gopher_mcp_python.ffi.auth.types import GopherAuthError
17
+
18
+
19
+ @dataclass
20
+ class AutoRefreshResult:
21
+ """Result of auto-refresh operation."""
22
+
23
+ valid: bool
24
+ new_access_token: Optional[str] = None
25
+ error_code: int = 0
26
+ error_message: Optional[str] = None
27
+
28
+
29
+ def gopher_auth_auto_refresh(
30
+ auth_client: GopherAuthClient,
31
+ oauth_client: GopherOAuthClient,
32
+ session_manager: GopherSessionManager,
33
+ session_id: str,
34
+ ) -> AutoRefreshResult:
35
+ """
36
+ Auto-refresh: validate token, refresh if expired, re-validate.
37
+
38
+ If the token is still valid, new_access_token is None.
39
+ If refreshed, new_access_token contains the new token.
40
+ """
41
+ funcs = get_auth_functions()
42
+ fn = funcs.get("auto_refresh")
43
+ if not fn:
44
+ return AutoRefreshResult(
45
+ valid=False,
46
+ error_code=GopherAuthError.NOT_INITIALIZED,
47
+ error_message="Auto-refresh function not available",
48
+ )
49
+
50
+ token_out = c_char_p()
51
+ result_out = GopherAuthValidationResult()
52
+
53
+ err = fn(
54
+ auth_client.get_handle(),
55
+ oauth_client.get_handle(),
56
+ session_manager.get_handle(),
57
+ session_id.encode("utf-8"),
58
+ byref(token_out),
59
+ byref(result_out),
60
+ )
61
+
62
+ if err != GopherAuthError.SUCCESS:
63
+ return AutoRefreshResult(
64
+ valid=False,
65
+ error_code=err,
66
+ error_message=(
67
+ result_out.error_message.decode("utf-8")
68
+ if result_out.error_message
69
+ else f"Error code {err}"
70
+ ),
71
+ )
72
+
73
+ new_token = None
74
+ if token_out.value:
75
+ new_token = token_out.value.decode("utf-8")
76
+ free = funcs.get("free_string")
77
+ if free:
78
+ free(token_out)
79
+
80
+ return AutoRefreshResult(
81
+ valid=result_out.valid,
82
+ new_access_token=new_token,
83
+ error_code=result_out.error_code,
84
+ error_message=(
85
+ result_out.error_message.decode("utf-8")
86
+ if result_out.error_message
87
+ else None
88
+ ),
89
+ )