amazon-ads-mcp 0.2.7__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.
Files changed (82) hide show
  1. amazon_ads_mcp/__init__.py +11 -0
  2. amazon_ads_mcp/auth/__init__.py +33 -0
  3. amazon_ads_mcp/auth/base.py +211 -0
  4. amazon_ads_mcp/auth/hooks.py +172 -0
  5. amazon_ads_mcp/auth/manager.py +791 -0
  6. amazon_ads_mcp/auth/oauth_state_store.py +277 -0
  7. amazon_ads_mcp/auth/providers/__init__.py +14 -0
  8. amazon_ads_mcp/auth/providers/direct.py +393 -0
  9. amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
  10. amazon_ads_mcp/auth/providers/openbridge.py +512 -0
  11. amazon_ads_mcp/auth/registry.py +146 -0
  12. amazon_ads_mcp/auth/secure_token_store.py +297 -0
  13. amazon_ads_mcp/auth/token_store.py +723 -0
  14. amazon_ads_mcp/config/__init__.py +5 -0
  15. amazon_ads_mcp/config/sampling.py +111 -0
  16. amazon_ads_mcp/config/settings.py +366 -0
  17. amazon_ads_mcp/exceptions.py +314 -0
  18. amazon_ads_mcp/middleware/__init__.py +11 -0
  19. amazon_ads_mcp/middleware/authentication.py +1474 -0
  20. amazon_ads_mcp/middleware/caching.py +177 -0
  21. amazon_ads_mcp/middleware/oauth.py +175 -0
  22. amazon_ads_mcp/middleware/sampling.py +112 -0
  23. amazon_ads_mcp/models/__init__.py +320 -0
  24. amazon_ads_mcp/models/amc_models.py +837 -0
  25. amazon_ads_mcp/models/api_responses.py +847 -0
  26. amazon_ads_mcp/models/base_models.py +215 -0
  27. amazon_ads_mcp/models/builtin_responses.py +496 -0
  28. amazon_ads_mcp/models/dsp_models.py +556 -0
  29. amazon_ads_mcp/models/stores_brands.py +610 -0
  30. amazon_ads_mcp/server/__init__.py +6 -0
  31. amazon_ads_mcp/server/__main__.py +6 -0
  32. amazon_ads_mcp/server/builtin_prompts.py +269 -0
  33. amazon_ads_mcp/server/builtin_tools.py +962 -0
  34. amazon_ads_mcp/server/file_routes.py +547 -0
  35. amazon_ads_mcp/server/html_templates.py +149 -0
  36. amazon_ads_mcp/server/mcp_server.py +327 -0
  37. amazon_ads_mcp/server/openapi_utils.py +158 -0
  38. amazon_ads_mcp/server/sampling_handler.py +251 -0
  39. amazon_ads_mcp/server/server_builder.py +751 -0
  40. amazon_ads_mcp/server/sidecar_loader.py +178 -0
  41. amazon_ads_mcp/server/transform_executor.py +827 -0
  42. amazon_ads_mcp/tools/__init__.py +22 -0
  43. amazon_ads_mcp/tools/cache_management.py +105 -0
  44. amazon_ads_mcp/tools/download_tools.py +267 -0
  45. amazon_ads_mcp/tools/identity.py +236 -0
  46. amazon_ads_mcp/tools/oauth.py +598 -0
  47. amazon_ads_mcp/tools/profile.py +150 -0
  48. amazon_ads_mcp/tools/profile_listing.py +285 -0
  49. amazon_ads_mcp/tools/region.py +320 -0
  50. amazon_ads_mcp/tools/region_identity.py +175 -0
  51. amazon_ads_mcp/utils/__init__.py +6 -0
  52. amazon_ads_mcp/utils/async_compat.py +215 -0
  53. amazon_ads_mcp/utils/errors.py +452 -0
  54. amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
  55. amazon_ads_mcp/utils/export_download_handler.py +579 -0
  56. amazon_ads_mcp/utils/header_resolver.py +81 -0
  57. amazon_ads_mcp/utils/http/__init__.py +56 -0
  58. amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
  59. amazon_ads_mcp/utils/http/client_manager.py +329 -0
  60. amazon_ads_mcp/utils/http/request.py +207 -0
  61. amazon_ads_mcp/utils/http/resilience.py +512 -0
  62. amazon_ads_mcp/utils/http/resilient_client.py +195 -0
  63. amazon_ads_mcp/utils/http/retry.py +76 -0
  64. amazon_ads_mcp/utils/http_client.py +873 -0
  65. amazon_ads_mcp/utils/media/__init__.py +21 -0
  66. amazon_ads_mcp/utils/media/negotiator.py +243 -0
  67. amazon_ads_mcp/utils/media/types.py +199 -0
  68. amazon_ads_mcp/utils/openapi/__init__.py +16 -0
  69. amazon_ads_mcp/utils/openapi/json.py +55 -0
  70. amazon_ads_mcp/utils/openapi/loader.py +263 -0
  71. amazon_ads_mcp/utils/openapi/refs.py +46 -0
  72. amazon_ads_mcp/utils/region_config.py +200 -0
  73. amazon_ads_mcp/utils/response_wrapper.py +171 -0
  74. amazon_ads_mcp/utils/sampling_helpers.py +156 -0
  75. amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
  76. amazon_ads_mcp/utils/security.py +630 -0
  77. amazon_ads_mcp/utils/tool_naming.py +137 -0
  78. amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
  79. amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
  80. amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
  81. amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
  82. amazon_ads_mcp-0.2.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,512 @@
1
+ """OpenBridge authentication provider.
2
+
3
+ This module implements the OpenBridge authentication provider,
4
+ which manages multiple Amazon Ads identities through OpenBridge's
5
+ remote identity service.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from datetime import datetime, timedelta, timezone
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import httpx
14
+ import jwt
15
+ from pydantic import BaseModel, ValidationError
16
+
17
+ from ...models import AuthCredentials, Identity, Token
18
+ from ...utils.http import get_http_client
19
+ from ..base import BaseAmazonAdsProvider, BaseIdentityProvider, ProviderConfig
20
+ from ..registry import register_provider
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class OpenbridgeTokenResponse(BaseModel):
26
+ """OpenBridge token response model.
27
+
28
+ Represents the response from OpenBridge when requesting an
29
+ access token for Amazon Ads API authentication.
30
+
31
+ :param data: Raw response data containing token information
32
+ :type data: Dict[str, Any]
33
+ """
34
+
35
+ data: Dict[str, Any]
36
+
37
+ def get_token(self) -> Optional[str]:
38
+ """Extract token from response.
39
+
40
+ Extracts the access token from the OpenBridge response data.
41
+
42
+ :return: Access token string if available, None otherwise
43
+ :rtype: Optional[str]
44
+ """
45
+ # The response has data.access_token directly
46
+ return self.data.get("access_token")
47
+
48
+ def get_client_id(self) -> Optional[str]:
49
+ """Extract client ID from response.
50
+
51
+ Extracts the client ID from the OpenBridge response data.
52
+ Checks multiple possible field names where the client ID might be stored.
53
+
54
+ :return: Client ID string if available, None otherwise
55
+ :rtype: Optional[str]
56
+ """
57
+ # Check multiple possible fields for client ID
58
+ # OpenBridge might use different field names
59
+ client_id = (
60
+ self.data.get("client_id")
61
+ or self.data.get("clientId")
62
+ or self.data.get("amazon_advertising_api_client_id")
63
+ or self.data.get("amazonAdvertisingApiClientId")
64
+ )
65
+ return client_id
66
+
67
+ def get_scope(self) -> Optional[str]:
68
+ """Extract scope/profile ID from response.
69
+
70
+ Extracts the scope (profile ID) from the OpenBridge response data.
71
+
72
+ :return: Scope/Profile ID string if available, None otherwise
73
+ :rtype: Optional[str]
74
+ """
75
+ # Check for scope or profile_id in the response
76
+ scope = (
77
+ self.data.get("scope")
78
+ or self.data.get("profile_id")
79
+ or self.data.get("profileId")
80
+ or self.data.get("amazon_advertising_api_scope")
81
+ )
82
+ return scope
83
+
84
+
85
+ @register_provider("openbridge")
86
+ class OpenBridgeProvider(BaseAmazonAdsProvider, BaseIdentityProvider):
87
+ """OpenBridge authentication provider.
88
+
89
+ Provides authentication and identity management through the OpenBridge
90
+ platform, handling JWT token conversion and remote identity access.
91
+ """
92
+
93
+ def __init__(self, config: ProviderConfig):
94
+ """Initialize OpenBridge provider.
95
+
96
+ :param config: Provider configuration
97
+ :type config: ProviderConfig
98
+ """
99
+ # Refresh token can come from config OR be provided later via Authorization header
100
+ # Don't require it at initialization time
101
+ self.refresh_token = config.get("refresh_token")
102
+
103
+ self._region = config.get("region", "na")
104
+
105
+ # OpenBridge API endpoints - configurable via config or env
106
+ self.auth_base_url = config.get("auth_base_url") or os.environ.get(
107
+ "OPENBRIDGE_AUTH_BASE_URL",
108
+ "https://authentication.api.openbridge.io",
109
+ )
110
+ self.identity_base_url = config.get("identity_base_url") or os.environ.get(
111
+ "OPENBRIDGE_IDENTITY_BASE_URL",
112
+ "https://remote-identity.api.openbridge.io",
113
+ )
114
+ self.service_base_url = config.get("service_base_url") or os.environ.get(
115
+ "OPENBRIDGE_SERVICE_BASE_URL", "https://service.api.openbridge.io"
116
+ )
117
+
118
+ self._jwt_token: Optional[Token] = None
119
+ self._identities_cache: Dict[tuple, List[Identity]] = {}
120
+
121
+ @property
122
+ def provider_type(self) -> str:
123
+ """Return the provider type identifier."""
124
+ return "openbridge"
125
+
126
+ @property
127
+ def region(self) -> str:
128
+ """Get the current region."""
129
+ return self._region
130
+
131
+ async def initialize(self) -> None:
132
+ """Initialize the provider."""
133
+ logger.info("Initializing OpenBridge provider")
134
+ # Could validate the refresh token here if needed
135
+
136
+ async def _get_client(self) -> httpx.AsyncClient:
137
+ """Get shared HTTP client."""
138
+ return await get_http_client()
139
+
140
+ def set_refresh_token(self, refresh_token: str) -> None:
141
+ """Set the refresh token dynamically.
142
+
143
+ This allows the refresh token to be provided via the Authorization header
144
+ rather than requiring it in the configuration.
145
+
146
+ :param refresh_token: The OpenBridge refresh token
147
+ :type refresh_token: str
148
+ """
149
+ self.refresh_token = refresh_token
150
+ # Clear cached JWT when refresh token changes
151
+ self._jwt_token = None
152
+
153
+ async def get_token(self) -> Token:
154
+ """Get current JWT token from OpenBridge."""
155
+ if self._jwt_token and await self.validate_token(self._jwt_token):
156
+ return self._jwt_token
157
+
158
+ if not self.refresh_token:
159
+ raise ValueError(
160
+ "No OpenBridge token available. Set OPENBRIDGE_REFRESH_TOKEN (or OPENBRIDGE_API_KEY), or pass it via Authorization header."
161
+ )
162
+
163
+ return await self._refresh_jwt_token()
164
+
165
+ async def _refresh_jwt_token(self) -> Token:
166
+ """Convert refresh token to JWT via OpenBridge."""
167
+ if not self.refresh_token:
168
+ raise ValueError("Cannot refresh JWT: No refresh token available")
169
+
170
+ logger.debug("Converting OpenBridge refresh token to JWT")
171
+
172
+ client = await self._get_client()
173
+
174
+ try:
175
+ response = await client.post(
176
+ f"{self.auth_base_url}/auth/api/ref",
177
+ json={
178
+ "data": {
179
+ "type": "APIAuth",
180
+ "attributes": {"refresh_token": self.refresh_token},
181
+ }
182
+ },
183
+ headers={"Content-Type": "application/json"},
184
+ )
185
+
186
+ if response.status_code not in [200, 202]:
187
+ response.raise_for_status()
188
+
189
+ data = response.json()
190
+ token_value = data.get("data", {}).get("attributes", {}).get("token")
191
+
192
+ if not token_value:
193
+ raise ValueError("No token in OpenBridge response")
194
+
195
+ # Parse the JWT to get expiration
196
+ payload = jwt.decode(token_value, options={"verify_signature": False})
197
+ expires_at_timestamp = payload.get("expires_at", 0)
198
+ expires_at = datetime.fromtimestamp(expires_at_timestamp, tz=timezone.utc)
199
+
200
+ self._jwt_token = Token(
201
+ value=token_value,
202
+ expires_at=expires_at,
203
+ token_type="Bearer",
204
+ metadata={
205
+ "user_id": payload.get("user_id"),
206
+ "account_id": payload.get("account_id"),
207
+ },
208
+ )
209
+
210
+ logger.debug(f"OpenBridge JWT obtained, expires at {expires_at}")
211
+ return self._jwt_token
212
+
213
+ except httpx.HTTPError as e:
214
+ logger.error(f"Failed to get OpenBridge JWT: {e}")
215
+ raise
216
+ except Exception as e:
217
+ logger.error(f"Error processing OpenBridge token: {e}")
218
+ raise
219
+
220
+ async def validate_token(self, token: Token) -> bool:
221
+ """Validate if token is still valid."""
222
+ buffer = timedelta(minutes=5)
223
+ now = datetime.now(timezone.utc)
224
+ expiry = token.expires_at
225
+ # Ensure both datetimes are timezone-aware for comparison
226
+ if expiry.tzinfo is None:
227
+ expiry = expiry.replace(tzinfo=timezone.utc)
228
+ return now < (expiry - buffer)
229
+
230
+ async def list_identities(self, **kwargs) -> List[Identity]:
231
+ """List all remote identities from OpenBridge.
232
+
233
+ :param kwargs: Optional filters (identity_type, page_size)
234
+ :return: List of identities
235
+ """
236
+ identity_type = kwargs.get("identity_type", "14") # Default to Amazon Ads
237
+ page_size = kwargs.get("page_size", 100)
238
+
239
+ cache_key = (identity_type, page_size)
240
+ if cache_key in self._identities_cache:
241
+ logger.debug(f"Using cached identities for {cache_key}")
242
+ return self._identities_cache[cache_key]
243
+
244
+ identities = await self._fetch_identities(identity_type, page_size)
245
+
246
+ # Simple cache management
247
+ if len(self._identities_cache) >= 32:
248
+ oldest_key = next(iter(self._identities_cache))
249
+ del self._identities_cache[oldest_key]
250
+
251
+ self._identities_cache[cache_key] = identities
252
+ return identities
253
+
254
+ async def _fetch_identities(
255
+ self, identity_type: str, page_size: int
256
+ ) -> List[Identity]:
257
+ """Fetch identities from OpenBridge API."""
258
+ logger.info(
259
+ f"Fetching remote identities from OpenBridge (type={identity_type})"
260
+ )
261
+
262
+ jwt_token = await self.get_token()
263
+ client = await self._get_client()
264
+ identities = []
265
+ page = 1
266
+ has_more = True
267
+
268
+ try:
269
+ while has_more:
270
+ logger.debug(f"Fetching page {page} of identities")
271
+ params = {"page": page, "page_size": page_size}
272
+
273
+ if identity_type:
274
+ params["remote_identity_type"] = identity_type
275
+
276
+ response = await client.get(
277
+ f"{self.identity_base_url}/sri",
278
+ params=params,
279
+ headers={
280
+ "Authorization": f"Bearer {jwt_token.value}",
281
+ "x-api-key": self.refresh_token,
282
+ },
283
+ timeout=httpx.Timeout(30.0, connect=10.0),
284
+ )
285
+ response.raise_for_status()
286
+
287
+ data = response.json()
288
+ items = data.get("data", [])
289
+ logger.debug(f"Page {page} has {len(items)} items")
290
+
291
+ for item in items:
292
+ try:
293
+ identity = Identity(**item)
294
+ identities.append(identity)
295
+ except ValidationError as e:
296
+ logger.warning(f"Failed to parse identity: {e}")
297
+ continue
298
+
299
+ # Check for more pages
300
+ links = data.get("links", {})
301
+ has_more = bool(links.get("next"))
302
+
303
+ if not items:
304
+ logger.debug(f"No items on page {page}, stopping pagination")
305
+ has_more = False
306
+
307
+ page += 1
308
+
309
+ if page > 100:
310
+ logger.warning("Reached maximum page limit (100)")
311
+ break
312
+
313
+ logger.info(f"Found {len(identities)} remote identities")
314
+ return identities
315
+
316
+ except httpx.HTTPError as e:
317
+ logger.error(f"Failed to list identities: {e}")
318
+ raise
319
+
320
+ async def get_identity(self, identity_id: str) -> Optional[Identity]:
321
+ """Get specific identity by ID."""
322
+ identities = await self.list_identities()
323
+ for identity in identities:
324
+ if identity.id == identity_id:
325
+ return identity
326
+ return None
327
+
328
+ async def get_identity_credentials(self, identity_id: str) -> AuthCredentials:
329
+ """Get Amazon Ads credentials for specific identity.
330
+
331
+ OpenBridge handles token refresh internally - each call to their
332
+ /service/amzadv/token/<id> endpoint returns a fresh, valid token.
333
+ We parse the expiration if possible to enable client-side caching.
334
+ """
335
+ logger.info(f"Getting credentials for identity {identity_id}")
336
+
337
+ identity = await self.get_identity(identity_id)
338
+ if not identity:
339
+ raise ValueError(f"Identity {identity_id} not found")
340
+
341
+ jwt_token = await self.get_token()
342
+ client = await self._get_client()
343
+
344
+ try:
345
+ response = await client.get(
346
+ f"{self.service_base_url}/service/amzadv/token/{identity_id}",
347
+ headers={
348
+ "Authorization": f"Bearer {jwt_token.value}",
349
+ "x-api-key": self.refresh_token,
350
+ },
351
+ )
352
+ response.raise_for_status()
353
+
354
+ data = response.json()
355
+ # Log sanitized response metadata (without sensitive tokens)
356
+ logger.debug(
357
+ "OpenBridge token response received",
358
+ extra={
359
+ "has_data": "data" in data,
360
+ "data_keys": list(data.get("data", {}).keys()) if "data" in data else [],
361
+ }
362
+ )
363
+
364
+ token_data = OpenbridgeTokenResponse(data=data.get("data", {}))
365
+ amazon_ads_token = token_data.get_token()
366
+ client_id = token_data.get_client_id()
367
+ scope = token_data.get_scope()
368
+
369
+ # Log what we extracted
370
+ logger.info(
371
+ f"Extracted from OpenBridge response - token: {bool(amazon_ads_token)}, client_id: {client_id}, scope: {scope}"
372
+ )
373
+
374
+ if not amazon_ads_token:
375
+ raise ValueError("No Amazon Ads token in response")
376
+
377
+ # Handle client ID fallback
378
+ if not client_id:
379
+ # Only fall back to env var if OpenBridge didn't provide a client ID
380
+ env_client_id = os.getenv("AMAZON_AD_API_CLIENT_ID")
381
+ if env_client_id:
382
+ logger.info(
383
+ "OpenBridge didn't provide client ID, using AMAZON_AD_API_CLIENT_ID env var"
384
+ )
385
+ client_id = env_client_id
386
+ else:
387
+ raise ValueError(
388
+ "No client ID from OpenBridge and AMAZON_AD_API_CLIENT_ID not set"
389
+ )
390
+ elif client_id.lower() == "openbridge":
391
+ # Legacy: Some older OpenBridge setups might return "openbridge" as placeholder
392
+ logger.warning(
393
+ "OpenBridge returned 'openbridge' as client ID placeholder. "
394
+ "Please update your OpenBridge configuration to provide the real client ID."
395
+ )
396
+ env_client_id = os.getenv("AMAZON_AD_API_CLIENT_ID")
397
+ if env_client_id:
398
+ logger.info("Using AMAZON_AD_API_CLIENT_ID env var as fallback")
399
+ client_id = env_client_id
400
+ else:
401
+ raise ValueError(
402
+ "OpenBridge returned 'openbridge' placeholder and AMAZON_AD_API_CLIENT_ID not set"
403
+ )
404
+
405
+ # Try to parse expiration from the Amazon token if it's a JWT
406
+ expires_at = None
407
+ try:
408
+ # Amazon tokens are usually JWTs we can decode
409
+ payload = jwt.decode(amazon_ads_token, options={"verify_signature": False})
410
+ # Check for standard JWT expiration claim
411
+ if "exp" in payload:
412
+ expires_at = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
413
+ logger.info(f"Parsed Amazon token expiration: {expires_at}")
414
+ elif "expires_at" in payload:
415
+ expires_at = datetime.fromtimestamp(payload["expires_at"], tz=timezone.utc)
416
+ logger.info(f"Parsed Amazon token expiration: {expires_at}")
417
+ except Exception as e:
418
+ logger.debug(f"Could not parse Amazon token as JWT: {e}")
419
+
420
+ # If we couldn't parse expiration, use a conservative default
421
+ # OpenBridge should always return fresh tokens, but we use a short
422
+ # expiration to ensure frequent refresh checks
423
+ if expires_at is None:
424
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=55)
425
+ logger.info("Using default 55-minute expiration for Amazon token")
426
+ else:
427
+ # Check if the token is already expired or about to expire
428
+ now = datetime.now(timezone.utc)
429
+ time_until_expiry = expires_at - now
430
+ if time_until_expiry.total_seconds() < 300: # Less than 5 minutes
431
+ logger.warning(
432
+ f"OpenBridge returned token expiring in {time_until_expiry.total_seconds():.0f} seconds!"
433
+ )
434
+ # OpenBridge should not return expired tokens, but log if it happens
435
+ if time_until_expiry.total_seconds() < 0:
436
+ logger.error("OpenBridge returned an EXPIRED token! This should not happen.")
437
+
438
+ # Get the identity's region for the correct endpoint
439
+ identity_region = identity.attributes.get("region", "na").lower()
440
+ logger.info(f"Using identity region: {identity_region}")
441
+
442
+ # Build headers with all available information
443
+ headers = {
444
+ "Authorization": f"Bearer {amazon_ads_token}",
445
+ "Amazon-Advertising-API-ClientId": client_id,
446
+ }
447
+
448
+ # Add scope if provided by OpenBridge
449
+ if scope:
450
+ headers["Amazon-Advertising-API-Scope"] = scope
451
+ logger.info(f"Using scope from OpenBridge: {scope}")
452
+
453
+ return AuthCredentials(
454
+ identity_id=identity_id,
455
+ access_token=amazon_ads_token,
456
+ expires_at=expires_at,
457
+ base_url=self.get_region_endpoint(identity_region),
458
+ headers=headers,
459
+ )
460
+
461
+ except httpx.HTTPError as e:
462
+ logger.error(f"Failed to get identity credentials: {e}")
463
+ raise
464
+
465
+ async def get_headers(self) -> Dict[str, str]:
466
+ """Get authentication headers.
467
+
468
+ For OpenBridge, this returns empty headers since all headers
469
+ come from the identity-specific credentials.
470
+ """
471
+ # OpenBridge headers are identity-specific and come from
472
+ # get_identity_credentials(). Return empty here to avoid
473
+ # overriding with incorrect values.
474
+ return {}
475
+
476
+ def requires_identity_region_routing(self) -> bool:
477
+ """Check if requests must be routed to identity's region.
478
+
479
+ OpenBridge always routes requests to the region associated
480
+ with the active identity.
481
+
482
+ :return: True - OpenBridge requires identity-based routing
483
+ :rtype: bool
484
+ """
485
+ return True
486
+
487
+ def headers_are_identity_specific(self) -> bool:
488
+ """Check if auth headers vary per identity.
489
+
490
+ OpenBridge uses different credentials for each identity,
491
+ so headers cannot be reconstructed from just a cached token.
492
+
493
+ :return: True - OpenBridge headers are identity-specific
494
+ :rtype: bool
495
+ """
496
+ return True
497
+
498
+ def region_controlled_by_identity(self) -> bool:
499
+ """Check if region is determined by active identity.
500
+
501
+ In OpenBridge, the region cannot be changed independently;
502
+ it's determined by the active identity's region.
503
+
504
+ :return: True - OpenBridge region is controlled by identity
505
+ :rtype: bool
506
+ """
507
+ return True
508
+
509
+ async def close(self) -> None:
510
+ """Clean up provider resources."""
511
+ self._identities_cache.clear()
512
+ self._jwt_token = None
@@ -0,0 +1,146 @@
1
+ """Manage registration and discovery of authentication providers.
2
+
3
+ Provide a simple registry to register, look up, and create providers
4
+ without modifying core code.
5
+
6
+ Examples
7
+ --------
8
+ .. code-block:: python
9
+
10
+ from amazon_ads_mcp.auth.registry import register_provider, ProviderRegistry
11
+ from amazon_ads_mcp.auth.base import BaseAuthProvider, ProviderConfig
12
+
13
+ @register_provider("example")
14
+ class ExampleProvider(BaseAuthProvider):
15
+ @property
16
+ def provider_type(self) -> str:
17
+ return "example"
18
+
19
+ async def initialize(self) -> None:
20
+ pass
21
+
22
+ async def get_token(self):
23
+ raise NotImplementedError
24
+
25
+ async def validate_token(self, token) -> bool:
26
+ return True
27
+
28
+ async def close(self) -> None:
29
+ pass
30
+
31
+ provider = ProviderRegistry.create_provider("example", ProviderConfig())
32
+ """
33
+
34
+ import logging
35
+ from typing import Dict, Optional, Type
36
+
37
+ from .base import BaseAuthProvider, ProviderConfig
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class ProviderRegistry:
43
+ """Registry for authentication providers.
44
+
45
+ Manage registration, lookup, and instantiation of providers.
46
+ """
47
+
48
+ _providers: Dict[str, Type[BaseAuthProvider]] = {}
49
+
50
+ @classmethod
51
+ def register(
52
+ cls, provider_type: str, provider_class: Type[BaseAuthProvider]
53
+ ) -> None:
54
+ """Register a provider class.
55
+
56
+ :param provider_type: Unique identifier for the provider type.
57
+ :param provider_class: Provider class to register.
58
+ :raises ValueError: If provider type is already registered.
59
+ """
60
+ if provider_type in cls._providers:
61
+ raise ValueError(f"Provider type '{provider_type}' is already registered")
62
+
63
+ cls._providers[provider_type] = provider_class
64
+ logger.info(
65
+ f"Registered provider: {provider_type} -> {provider_class.__name__}"
66
+ )
67
+
68
+ @classmethod
69
+ def unregister(cls, provider_type: str) -> None:
70
+ """Unregister a provider.
71
+
72
+ Remove a provider from the registry so it cannot be instantiated.
73
+
74
+ :param provider_type: Provider type to unregister.
75
+ """
76
+ if provider_type in cls._providers:
77
+ del cls._providers[provider_type]
78
+ logger.info(f"Unregistered provider: {provider_type}")
79
+
80
+ @classmethod
81
+ def get_provider_class(cls, provider_type: str) -> Optional[Type[BaseAuthProvider]]:
82
+ """Return a registered provider class.
83
+
84
+ :param provider_type: Provider type to retrieve.
85
+ :return: Provider class if registered, otherwise None.
86
+ """
87
+ return cls._providers.get(provider_type)
88
+
89
+ @classmethod
90
+ def create_provider(
91
+ cls, provider_type: str, config: ProviderConfig
92
+ ) -> BaseAuthProvider:
93
+ """Create a provider instance.
94
+
95
+ :param provider_type: Type of provider to create.
96
+ :param config: Configuration for the provider.
97
+ :return: Provider instance.
98
+ :raises ValueError: If provider type is not registered.
99
+ """
100
+ provider_class = cls.get_provider_class(provider_type)
101
+ if not provider_class:
102
+ available = ", ".join(cls._providers.keys())
103
+ raise ValueError(
104
+ f"Unknown provider type: '{provider_type}'. "
105
+ f"Available providers: {available or 'none'}"
106
+ )
107
+
108
+ return provider_class(config)
109
+
110
+ @classmethod
111
+ def list_providers(cls) -> Dict[str, Type[BaseAuthProvider]]:
112
+ """List all registered providers.
113
+
114
+ :return: Mapping of provider types to classes.
115
+ """
116
+ return cls._providers.copy()
117
+
118
+ @classmethod
119
+ def clear(cls) -> None:
120
+ """Clear all registered providers.
121
+
122
+ Remove all providers. Useful for tests to ensure clean state.
123
+ """
124
+ cls._providers.clear()
125
+
126
+
127
+ def register_provider(provider_type: str):
128
+ """Return a decorator to auto-register a provider class.
129
+
130
+ Usage
131
+ -----
132
+ .. code-block:: python
133
+
134
+ @register_provider("my_provider")
135
+ class MyProvider(BaseAuthProvider):
136
+ ...
137
+
138
+ :param provider_type: Type identifier for the provider.
139
+ :return: Decorator function.
140
+ """
141
+
142
+ def decorator(provider_class: Type[BaseAuthProvider]):
143
+ ProviderRegistry.register(provider_type, provider_class)
144
+ return provider_class
145
+
146
+ return decorator