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,873 @@
1
+ """Enhanced HTTP client for Amazon Ads API authentication.
2
+
3
+ This module provides an authenticated HTTP client that manages headers
4
+ for Amazon Advertising API calls. The client automatically handles
5
+ authentication, regional routing, and content negotiation.
6
+
7
+ Key Features:
8
+
9
+ - Automatic authentication header injection
10
+ - Regional endpoint routing based on identity/marketplace
11
+ - Media type negotiation with OpenAPI specs
12
+ - Header scrubbing to remove conflicting headers
13
+ - Special handling for different API endpoints
14
+ - Response shaping for large AMC payloads
15
+
16
+ Examples:
17
+ >>> client = AuthenticatedClient(auth_manager=auth_mgr)
18
+ >>> response = await client.get("/v2/profiles")
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ from contextvars import ContextVar
25
+ from datetime import datetime, timezone
26
+ from typing import Any, Dict, List, Optional
27
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
28
+
29
+ import httpx
30
+
31
+ from ..config.settings import Settings
32
+ from ..utils.export_content_type_resolver import (
33
+ resolve_download_accept_headers,
34
+ )
35
+ from ..utils.header_resolver import HeaderNameResolver
36
+ from ..utils.media import MediaTypeRegistry
37
+ from ..utils.region_config import RegionConfig
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Context-local routing overrides/state to avoid cross-request leakage
42
+ _REGION_OVERRIDE_VAR: ContextVar[Optional[str]] = ContextVar(
43
+ "amazon_ads_region_override", default=None
44
+ )
45
+ # Marketplace override removed - deprecated functionality
46
+ _ROUTING_STATE_VAR: ContextVar[Dict[str, Any]] = ContextVar(
47
+ "amazon_ads_routing_state", default={}
48
+ )
49
+
50
+
51
+ class AuthenticatedClient(httpx.AsyncClient):
52
+ """Enhanced HTTP client that manages Amazon Ads API authentication headers.
53
+
54
+ This client extends httpx.AsyncClient to provide automatic header management for
55
+ Amazon Advertising API calls. It handles header scrubbing, injection, and media
56
+ type negotiation.
57
+
58
+ The client intercepts all HTTP requests and performs:
59
+
60
+ 1. Media type negotiation based on OpenAPI specifications
61
+ 2. Removal of conflicting or polluted headers
62
+ 3. Injection of proper Amazon Ads authentication headers
63
+ 4. Regional endpoint routing based on identity/marketplace
64
+ 5. Special handling for different API endpoint families
65
+
66
+ Key Features:
67
+
68
+ - Removes polluted headers from FastMCP
69
+ - Injects correct Amazon authentication headers
70
+ - Handles media type negotiation
71
+ - Manages client ID fallbacks
72
+ - Supports profile-specific header rules
73
+ - Automatic regional endpoint routing
74
+ - Response shaping for large AMC responses
75
+
76
+ :param auth_manager: Authentication manager for header generation
77
+ :type auth_manager: Optional[AuthManager]
78
+ :param media_registry: Registry for content type negotiation
79
+ :type media_registry: Optional[MediaTypeRegistry]
80
+ :param header_resolver: Resolver for header name variations
81
+ :type header_resolver: Optional[HeaderNameResolver]
82
+ :raises httpx.RequestError: When required auth headers are missing
83
+
84
+ .. example::
85
+ >>> auth_mgr = get_auth_manager()
86
+ >>> client = AuthenticatedClient(auth_manager=auth_mgr)
87
+ >>> response = await client.get("/v2/campaigns")
88
+ """
89
+
90
+ # anything we consider "polluted" and must remove if present
91
+ _FORBID_SUBSTRS = (
92
+ "authorization", # bearer from MCP client
93
+ "amazon-ads-clientid",
94
+ "amazon-advertising-api-clientid",
95
+ "amazon-advertising-api-scope",
96
+ "amazon-ads-accountid",
97
+ "x-amz-access-token",
98
+ )
99
+
100
+ def __init__(
101
+ self,
102
+ *args,
103
+ auth_manager=None,
104
+ media_registry=None,
105
+ header_resolver=None,
106
+ **kwargs,
107
+ ):
108
+ super().__init__(*args, **kwargs)
109
+ self.auth_manager = auth_manager
110
+ self.media_registry: Optional[MediaTypeRegistry] = media_registry
111
+ self.header_resolver: HeaderNameResolver = (
112
+ header_resolver or HeaderNameResolver()
113
+ )
114
+
115
+ async def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
116
+ """Single interception point for all HTTP requests.
117
+
118
+ This method is called for ALL requests, whether from:
119
+ - Direct client.request() calls (which build a Request then call send)
120
+ - FastMCP's OpenAPITool (which builds a Request then calls send)
121
+
122
+ It handles:
123
+ 1. Media type negotiation based on OpenAPI specs
124
+ 2. Header scrubbing to remove polluted auth headers
125
+ 3. Injection of correct Amazon authentication headers
126
+ 4. Special handling for profiles API endpoints
127
+
128
+ :param request: The HTTP request to send
129
+ :type request: httpx.Request
130
+ :param kwargs: Additional arguments to pass to the parent send method
131
+ :type kwargs: Dict[str, Any]
132
+ :return: The HTTP response
133
+ :rtype: httpx.Response
134
+ :raises httpx.RequestError: When required auth headers are missing
135
+ """
136
+ # Check if already processed (idempotent) - CRITICAL for preventing double injection
137
+ if request.extensions.get("auth_injected"):
138
+ logger.debug(
139
+ f"Request already processed, skipping injection: {request.method} {request.url}"
140
+ )
141
+ return await super().send(request, **kwargs)
142
+
143
+ # Mark as processing to prevent concurrent/recursive injection
144
+ request.extensions["auth_injected"] = True
145
+
146
+ # Log incoming request for debugging (only once)
147
+ logger.debug(f"=== SEND: {request.method} {request.url}")
148
+ logger.debug(f" Headers before injection: {list(request.headers.keys())}")
149
+
150
+ # Inject headers (only once)
151
+ await self._inject_headers(request)
152
+
153
+ logger.debug(f"Headers injected for {request.method} {request.url}")
154
+ logger.debug(f" Headers after injection: {list(request.headers.keys())}")
155
+
156
+ # Log critical headers for debugging
157
+ if logger.isEnabledFor(logging.DEBUG):
158
+ logger.debug(f" Accept: {request.headers.get('accept', 'NOT SET')}")
159
+ logger.debug(
160
+ f" Content-Type: {request.headers.get('content-type', 'NOT SET')}"
161
+ )
162
+ # Verify auth header is present
163
+ if "authorization" in request.headers:
164
+ auth_val = request.headers["authorization"]
165
+ logger.debug(
166
+ f" Authorization present: Bearer [{len(auth_val) - 7} chars]"
167
+ )
168
+
169
+ # Log the actual request headers right before sending
170
+ logger.debug("=== ACTUAL REQUEST BEING SENT ===")
171
+ logger.debug(f"URL: {request.url}")
172
+ logger.debug("Headers:")
173
+ for k, v in request.headers.items():
174
+ if k.lower() in [
175
+ "authorization",
176
+ "amazon-advertising-api-clientid",
177
+ ]:
178
+ logger.debug(f" {k}: [REDACTED]")
179
+ else:
180
+ logger.debug(f" {k}: {v}")
181
+
182
+ # Call parent's send
183
+ resp = await super().send(request, **kwargs)
184
+
185
+ # Best-effort fallback shaping for AMC endpoints when FastMCP transforms are unavailable
186
+ try:
187
+ ct = (resp.headers.get("Content-Type") or "").lower()
188
+ if (
189
+ "application/json" in ct
190
+ or "/json" in ct
191
+ or ct.startswith("application/vnd.")
192
+ ):
193
+ shaped = self._maybe_shape_amc_response(request, resp)
194
+ if shaped is not None:
195
+ # Create new response with shaped content (avoid _content manipulation)
196
+ payload = json.dumps(shaped, ensure_ascii=False).encode("utf-8")
197
+
198
+ # Build new response object
199
+ resp = httpx.Response(
200
+ status_code=resp.status_code,
201
+ headers=dict(resp.headers),
202
+ content=payload,
203
+ request=request,
204
+ )
205
+ resp.headers["content-length"] = str(len(payload))
206
+ except Exception as e:
207
+ # Log but don't fail
208
+ logger.debug(f"AMC response shaping failed: {e}")
209
+
210
+ return resp
211
+
212
+ def _maybe_shape_amc_response(
213
+ self, request: httpx.Request, response: httpx.Response
214
+ ) -> Optional[dict]:
215
+ """Truncate ONLY large AMC (Analytics/Measurement) JSON responses.
216
+
217
+ This is a runtime fallback for environments where FastMCP's
218
+ transform_tool is not supported, ensuring clients don't receive
219
+ extremely large AMC payloads in chat context.
220
+
221
+ IMPORTANT: This should NOT apply to regular API endpoints like
222
+ /v2/profiles, /v2/campaigns, etc. as they have proper pagination
223
+ and clients need the full data.
224
+ """
225
+ try:
226
+ method = (request.method or "").upper()
227
+ if method not in ("GET", "POST", "PUT"):
228
+ return None
229
+ url = str(request.url)
230
+ path = urlparse(url).path or ""
231
+ data = response.json()
232
+
233
+ # Only shape dict/array JSON
234
+ if not isinstance(data, (dict, list)):
235
+ return None
236
+
237
+ # Determine cap based on endpoint family
238
+ cap = None
239
+ p = path.lower()
240
+ if "/amc/reporting/" in p:
241
+ if p.endswith("/datasources") and method == "GET":
242
+ cap = 3
243
+ elif "/datasources/" in p and method == "GET":
244
+ cap = 5
245
+ elif p.endswith("/workflows") and method == "GET":
246
+ cap = 10
247
+ elif "/workflowexecutions" in p and method == "GET":
248
+ cap = 10
249
+ else:
250
+ cap = 10
251
+ elif "/amc/audiences/" in p:
252
+ if "/connections" in p and method == "GET":
253
+ cap = 10
254
+ elif "/metadata/" in p and method == "GET":
255
+ cap = 10
256
+ elif "/records/" in p and method == "GET":
257
+ cap = 10
258
+ elif "/query" in p and method == "GET":
259
+ cap = 10
260
+ else:
261
+ cap = 10
262
+
263
+ # DO NOT shape regular v2 API endpoints - they have proper pagination
264
+ # Response shaping should ONLY apply to known large AMC responses
265
+ # that don't have good pagination support
266
+
267
+ if cap is None:
268
+ return None
269
+
270
+ return self._truncate_lists(data, cap)
271
+ except Exception:
272
+ return None
273
+
274
+ def _truncate_lists(self, data: Any, n: int) -> Any:
275
+ try:
276
+
277
+ def walk(obj: Any) -> Any:
278
+ if isinstance(obj, list):
279
+ return [walk(x) for x in obj[: max(0, n)]]
280
+ if isinstance(obj, dict):
281
+ return {k: walk(v) for k, v in obj.items()}
282
+ return obj
283
+
284
+ return walk(data)
285
+ except Exception:
286
+ return data
287
+
288
+ def _map_auth_headers_to_spec(self, auth_headers: Dict[str, str]) -> Dict[str, str]:
289
+ """Map authentication headers to their OpenAPI spec preferred names.
290
+
291
+ This handles header name normalization, ensuring that various forms of
292
+ header names (e.g., Client-Id, ClientId) map to the preferred form from
293
+ the OpenAPI specification.
294
+
295
+ The mapping process consolidates header variants into the preferred
296
+ names defined by the header resolver, ensuring consistency across
297
+ different API endpoints and specifications.
298
+
299
+ :param auth_headers: Original authentication headers
300
+ :type auth_headers: Dict[str, str]
301
+ :return: Mapped headers using preferred names
302
+ :rtype: Dict[str, str]
303
+
304
+ .. example::
305
+ >>> headers = {"Client-Id": "abc123", "Scope": "456"}
306
+ >>> mapped = client._map_auth_headers_to_spec(headers)
307
+ >>> # Returns {"Amazon-Advertising-API-ClientId": "abc123", ...}
308
+ """
309
+ out: Dict[str, str] = dict(auth_headers)
310
+
311
+ pref_client = (
312
+ self.header_resolver.prefer_client() or "Amazon-Advertising-API-ClientId"
313
+ )
314
+ pref_scope = (
315
+ self.header_resolver.prefer_scope() or "Amazon-Advertising-API-Scope"
316
+ )
317
+ pref_acct = self.header_resolver.prefer_account() or "Amazon-Ads-AccountId"
318
+
319
+ # Normalize to preferred keys when variants exist
320
+ def move_first(src_keys: List[str], dest_key: str) -> None:
321
+ for s in src_keys:
322
+ if s in out and out[s]:
323
+ out[dest_key] = out.pop(s)
324
+ return
325
+
326
+ move_first(
327
+ [
328
+ "Amazon-Advertising-API-ClientId",
329
+ "Amazon-Ads-ClientId",
330
+ "Client-Id",
331
+ "ClientId",
332
+ ],
333
+ pref_client,
334
+ )
335
+ move_first(
336
+ ["Amazon-Advertising-API-Scope", "Amazon-Ads-Scope", "Scope"],
337
+ pref_scope,
338
+ )
339
+ move_first(
340
+ ["Amazon-Ads-AccountId", "Account-Id", "AccountId"],
341
+ pref_acct,
342
+ )
343
+
344
+ return out
345
+
346
+ def _get_env_client_id(self, current_value: str = "") -> Optional[str]:
347
+ """Get client ID from environment variables.
348
+
349
+ Prefers `AMAZON_AD_API_CLIENT_ID` (new naming) and falls back to
350
+ `AMAZON_ADS_CLIENT_ID` (legacy), returning the first non-empty value.
351
+
352
+ This method handles client ID resolution when the authentication
353
+ provider doesn't supply a client ID or provides a placeholder value
354
+ like "openbridge".
355
+
356
+ :param current_value: Current client ID value (empty or 'openbridge')
357
+ :type current_value: str
358
+ :return: Client ID from environment or None
359
+ :rtype: Optional[str]
360
+
361
+ .. note::
362
+ AMAZON_AD_API_CLIENT_ID takes precedence over AMAZON_ADS_CLIENT_ID
363
+ """
364
+ preferred = os.getenv("AMAZON_AD_API_CLIENT_ID")
365
+ legacy = os.getenv("AMAZON_ADS_CLIENT_ID")
366
+ env_client_id = preferred or legacy
367
+
368
+ if env_client_id:
369
+ env_name = (
370
+ "AMAZON_AD_API_CLIENT_ID" if preferred else "AMAZON_ADS_CLIENT_ID"
371
+ )
372
+ if not current_value:
373
+ logger.info(f"Using {env_name} from environment")
374
+ else:
375
+ logger.info(
376
+ f"Replacing 'openbridge' placeholder with {env_name} from environment"
377
+ )
378
+ return env_client_id
379
+
380
+ # Nothing set in environment
381
+ if not current_value:
382
+ logger.debug(
383
+ "No ClientId provided and no environment variable set. "
384
+ "Set AMAZON_AD_API_CLIENT_ID (preferred) or AMAZON_ADS_CLIENT_ID."
385
+ )
386
+ else:
387
+ logger.debug(
388
+ f"ClientId '{current_value}' provided but checking for env override"
389
+ )
390
+ return None
391
+
392
+ async def _inject_headers(self, request: httpx.Request) -> None:
393
+ """Inject authentication and media headers into a request.
394
+
395
+ Handles media negotiation, header scrubbing, auth header injection,
396
+ and profiles endpoint special-casing.
397
+
398
+ This method performs the core request transformation:
399
+
400
+ 1. Media type negotiation using the media registry
401
+ 2. Removal of polluted/conflicting headers
402
+ 3. Regional endpoint routing based on marketplace/identity
403
+ 4. Authentication header injection
404
+ 5. Special handling for specific endpoints
405
+
406
+ :param request: The HTTP request to modify
407
+ :type request: httpx.Request
408
+ :raises httpx.RequestError: When authentication fails or is missing
409
+
410
+ .. note::
411
+ This method modifies the request object in-place
412
+ """
413
+ method = request.method
414
+ url = str(request.url)
415
+ path = urlparse(url).path
416
+
417
+ # 1) MEDIA NEGOTIATION
418
+ if self.media_registry:
419
+ content_type, accepts = self.media_registry.resolve(method, url)
420
+ if content_type and method.lower() != "get":
421
+ request.headers["Content-Type"] = content_type
422
+ # Respect pre-existing Accept header from upstream transforms/tools
423
+ if accepts and "Accept" not in request.headers:
424
+ preferred = next(
425
+ (a for a in accepts if a.startswith("application/vnd.")),
426
+ accepts[0],
427
+ )
428
+ request.headers["Accept"] = preferred
429
+
430
+ # Heuristic Accept override for known download/report endpoints
431
+ if (
432
+ "Accept" not in request.headers
433
+ or (request.headers.get("Accept") or "").strip() == "*/*"
434
+ ):
435
+ try:
436
+ overrides = resolve_download_accept_headers(method, url)
437
+ if overrides:
438
+ if self.media_registry:
439
+ # Intersect with available accepts if we have them
440
+ _, accepts = self.media_registry.resolve(method, url)
441
+ if accepts:
442
+ for ct in overrides:
443
+ if ct in accepts:
444
+ request.headers["Accept"] = ct
445
+ break
446
+ else:
447
+ request.headers["Accept"] = overrides[0]
448
+ else:
449
+ request.headers["Accept"] = overrides[0]
450
+ else:
451
+ request.headers["Accept"] = overrides[0]
452
+ except Exception as e:
453
+ logger.debug("Accept override skipped: %s", e)
454
+
455
+ # 2) STRIP POLLUTED HEADERS
456
+ removed = []
457
+ for key in list(request.headers.keys()):
458
+ if any(s in key.lower() for s in self._FORBID_SUBSTRS):
459
+ removed.append(key)
460
+ del request.headers[key]
461
+ if removed:
462
+ logger.debug("🧹 Scrubbed headers from request: %s", removed)
463
+
464
+ # 2a) Dynamic region routing for Amazon Ads endpoints based on marketplace/profile
465
+ try:
466
+ p = path or ""
467
+ # Apply to all Amazon Ads API paths: /v2/, /reporting/, /amc/, etc.
468
+ if p.startswith("/v2/") or p.startswith("/reporting/") or "/amc/" in p:
469
+ # Determine desired region: explicit override header, marketplace mapping, or env
470
+ hdr = request.headers
471
+ # Marketplace override removed - deprecated functionality
472
+ # 1) Explicit session override or env
473
+ region_override = (
474
+ (
475
+ (_REGION_OVERRIDE_VAR.get() or "")
476
+ or os.getenv("ADS_REGION_OVERRIDE", "")
477
+ )
478
+ .strip()
479
+ .lower()
480
+ )
481
+ region = (
482
+ region_override if region_override in {"na", "eu", "fe"} else None
483
+ )
484
+ source = None
485
+ if region:
486
+ source = "override"
487
+
488
+ # 2) Map marketplace → region if provided
489
+ mp = hdr.get("Amazon-Advertising-API-MarketplaceId") or hdr.get(
490
+ "Amazon-Ads-MarketplaceId"
491
+ )
492
+ if not region and mp:
493
+ mp = mp.strip()
494
+ eu_mps = {
495
+ "A1PA6795UKMFR9", # DE
496
+ "A1F83G8C2ARO7P", # UK
497
+ "A13V1IB3VIYZZH", # FR
498
+ "APJ6JRA9NG5V4", # IT
499
+ "A1RKKUPIHCS9HS", # ES
500
+ "A1805IZSGTT6HS", # NL
501
+ "A2NODRKZP88ZB9", # SE
502
+ "A1C3SOZRARQ6R3", # PL
503
+ }
504
+ na_mps = {
505
+ "ATVPDKIKX0DER", # US
506
+ "A2EUQ1WTGCTBG2", # CA
507
+ "A1AM78C64UM0Y8", # MX
508
+ }
509
+ fe_mps = {
510
+ "A1VC38T7YXB528", # JP
511
+ "A39IBJ37TRP1C6", # AU
512
+ "A19VAU5U5O7RUS", # SG
513
+ }
514
+ if mp in eu_mps:
515
+ region = "eu"
516
+ source = "marketplace"
517
+ elif mp in na_mps:
518
+ region = "na"
519
+ source = "marketplace"
520
+ elif mp in fe_mps:
521
+ region = "fe"
522
+ source = "marketplace"
523
+
524
+ # 3) Active identity region
525
+ if not region and self.auth_manager:
526
+ try:
527
+ active_identity = self.auth_manager.get_active_identity()
528
+ logger.debug(
529
+ f"Auth manager active identity: {active_identity.id if active_identity else None}"
530
+ )
531
+ ident_region = self.auth_manager.get_active_region()
532
+ logger.debug(f"Auth manager active region: {ident_region}")
533
+ if ident_region:
534
+ region = ident_region
535
+ source = "identity"
536
+ except Exception as e:
537
+ logger.debug(f"Failed to get active region: {e}")
538
+
539
+ # 4) If still unknown, fall back to configured settings region
540
+ if not region:
541
+ try:
542
+ region = Settings().amazon_ads_region
543
+ except Exception:
544
+ region = "na"
545
+ source = source or "fallback"
546
+
547
+ # Compute desired host
548
+ host = RegionConfig.get_api_host(region)
549
+ if Settings().amazon_ads_sandbox_mode:
550
+ host = host.replace("advertising-api", "advertising-api-test")
551
+
552
+ # Rewrite request URL host if different
553
+ u = urlparse(str(request.url))
554
+ if u.netloc and u.netloc != host:
555
+ new_url = urlunparse(
556
+ (u.scheme, host, u.path, u.params, u.query, u.fragment)
557
+ )
558
+ request.url = httpx.URL(new_url)
559
+ # IMPORTANT: Also update the Host header to match the new URL
560
+ request.headers["host"] = host
561
+ _ROUTING_STATE_VAR.set(
562
+ {
563
+ "override": _REGION_OVERRIDE_VAR.get(),
564
+ "source": source,
565
+ "region": region,
566
+ "host": host,
567
+ "marketplace": None, # Deprecated
568
+ }
569
+ )
570
+ logger.debug(
571
+ "Region routing decided: source=%s region=%s host=%s",
572
+ source,
573
+ region,
574
+ host,
575
+ )
576
+ except Exception:
577
+ pass
578
+
579
+ # 2b) Normalize AMC time query params to expected ISO format (no timezone suffix)
580
+ try:
581
+ p_lower = (path or "").lower()
582
+ if "/amc/" in p_lower and "/workflowexecutions" in p_lower:
583
+ url_obj = urlparse(url)
584
+ q = dict(parse_qsl(url_obj.query, keep_blank_values=True))
585
+
586
+ def to_amc_iso(val: str) -> str:
587
+ s = (val or "").strip()
588
+ if not s:
589
+ return s
590
+ # numeric seconds or ms -> convert to ISO 'YYYY-MM-DDTHH:MM:SS'
591
+ if s.isdigit():
592
+ n = int(s)
593
+ # scale seconds to ms if needed, then to UTC ISO (no 'Z')
594
+ if n < 10**12:
595
+ n *= 1000
596
+ dt = datetime.fromtimestamp(n / 1000, tz=timezone.utc)
597
+ return dt.strftime("%Y-%m-%dT%H:%M:%S")
598
+ # ISO formats and dates -> normalize to no-suffix ISO
599
+ try:
600
+ iso = s
601
+ if iso.endswith("Z"):
602
+ iso = iso[:-1] + "+00:00"
603
+ if len(iso) == 10 and iso.count("-") == 2:
604
+ iso = iso + "T00:00:00+00:00"
605
+ if len(s) == 8 and s.isdigit():
606
+ iso = f"{s[0:4]}-{s[4:6]}-{s[6:8]}T00:00:00+00:00"
607
+ dt = datetime.fromisoformat(iso)
608
+ if dt.tzinfo is None:
609
+ dt = dt.replace(tzinfo=timezone.utc)
610
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
611
+ except Exception:
612
+ return s
613
+
614
+ changed = False
615
+ for key in (
616
+ "minCreationTime",
617
+ "maxCreationTime",
618
+ "startTime",
619
+ "endTime",
620
+ ):
621
+ if key in q:
622
+ newv = to_amc_iso(q[key])
623
+ if newv != q[key]:
624
+ q[key] = newv
625
+ changed = True
626
+ if changed:
627
+ new_query = urlencode(q, doseq=True)
628
+ new_url = urlunparse(
629
+ (
630
+ url_obj.scheme,
631
+ url_obj.netloc,
632
+ url_obj.path,
633
+ url_obj.params,
634
+ new_query,
635
+ url_obj.fragment,
636
+ )
637
+ )
638
+ request.url = httpx.URL(new_url)
639
+ except Exception:
640
+ pass
641
+
642
+ # 3) AUTH-AWARE REGIONAL ENDPOINT FIX AND CORRECT AUTH HEADERS (only for Amazon Ads API calls)
643
+ # Get the current URL (may have been modified by previous steps)
644
+ current_url = str(request.url)
645
+ parsed_url = urlparse(current_url)
646
+ u_host = (parsed_url.hostname or parsed_url.netloc or "").lower()
647
+ # Validate hostname is legitimate Amazon domain
648
+ is_amazon_ads_domain = (
649
+ "advertising-api" in u_host
650
+ and (u_host.endswith(".amazon.com") or u_host == "amazon.com")
651
+ )
652
+ is_ads_api = (
653
+ is_amazon_ads_domain
654
+ or path.startswith("/v2/")
655
+ or path.startswith("/reporting/")
656
+ or ("/amc/" in path)
657
+ )
658
+ if is_ads_api:
659
+ # Auth-aware URL rewriting
660
+ if self.auth_manager:
661
+ # Check if provider requires identity-based region routing
662
+ requires_identity_routing = (
663
+ hasattr(
664
+ self.auth_manager.provider,
665
+ "requires_identity_region_routing",
666
+ )
667
+ and self.auth_manager.provider.requires_identity_region_routing()
668
+ )
669
+
670
+ if requires_identity_routing:
671
+ # Provider requires routing to match identity's region (EVERY request)
672
+ identity = self.auth_manager.get_active_identity()
673
+ if identity:
674
+ identity_region = identity.attributes.get(
675
+ "region", "na"
676
+ ).lower()
677
+
678
+ # Map region to correct endpoint
679
+ correct_host = RegionConfig.get_api_host(identity_region)
680
+
681
+ # ALWAYS update URL to match identity's region for this provider
682
+ parsed = urlparse(current_url)
683
+ if parsed.netloc != correct_host:
684
+ new_url = urlunparse(
685
+ (
686
+ parsed.scheme,
687
+ correct_host,
688
+ parsed.path,
689
+ parsed.params,
690
+ parsed.query,
691
+ parsed.fragment,
692
+ )
693
+ )
694
+ request.url = httpx.URL(new_url)
695
+ # Also update Host header to match
696
+ request.headers["host"] = correct_host
697
+ logger.debug(
698
+ f"{self.auth_manager.provider.provider_type}: Routing request to {identity_region.upper()} endpoint: {correct_host} (identity: {identity.id})"
699
+ )
700
+ else:
701
+ logger.warning(
702
+ f"{self.auth_manager.provider.provider_type}: No active identity set, cannot determine correct region"
703
+ )
704
+
705
+ else:
706
+ # DIRECT AUTH: Do NOT rewrite host unless explicit override is set
707
+ region_override = _REGION_OVERRIDE_VAR.get()
708
+ if region_override and region_override in {
709
+ "na",
710
+ "eu",
711
+ "fe",
712
+ }:
713
+ override_host = RegionConfig.get_api_host(region_override)
714
+ parsed = urlparse(current_url)
715
+ if parsed.netloc != override_host:
716
+ new_url = urlunparse(
717
+ (
718
+ parsed.scheme,
719
+ override_host,
720
+ parsed.path,
721
+ parsed.params,
722
+ parsed.query,
723
+ parsed.fragment,
724
+ )
725
+ )
726
+ request.url = httpx.URL(new_url)
727
+ request.headers["host"] = override_host
728
+ logger.debug(
729
+ f"Direct auth: Applied region override to {region_override.upper()}: {override_host}"
730
+ )
731
+ # Otherwise, trust the base URL set during initialization for Direct auth
732
+
733
+ # Get fresh auth headers for EVERY request (critical for OpenBridge)
734
+ auth_headers: Dict[str, str] = {}
735
+ if self.auth_manager is not None:
736
+ logger.info(f"Getting auth headers for request to {path}")
737
+ try:
738
+ headers = await self.auth_manager.get_headers()
739
+ logger.info(f"Got auth headers: {list(headers.keys())}")
740
+ auth_headers.update(headers)
741
+ except Exception as e:
742
+ # Do not send unauthenticated requests to Amazon Ads API
743
+ raise httpx.RequestError(
744
+ f"Authentication required: {e}. Set an active identity or configure authentication.",
745
+ request=request,
746
+ )
747
+ else:
748
+ raise httpx.RequestError(
749
+ "Authentication manager unavailable; cannot build auth headers.",
750
+ request=request,
751
+ )
752
+
753
+ # Map to spec-preferred names
754
+ auth_headers = self._map_auth_headers_to_spec(auth_headers)
755
+
756
+ # Handle missing client ID (should not happen with properly configured OpenBridge)
757
+ client_id = auth_headers.get("Amazon-Advertising-API-ClientId")
758
+ if not client_id:
759
+ # Try to get from environment as last resort
760
+ env_client_id = self._get_env_client_id(current_value="")
761
+ if env_client_id:
762
+ logger.warning(
763
+ "No client ID in auth headers, using environment variable"
764
+ )
765
+ auth_headers["Amazon-Advertising-API-ClientId"] = env_client_id
766
+
767
+ # Validate mandatory headers
768
+ if not (
769
+ auth_headers.get("Authorization")
770
+ or request.headers.get("Authorization")
771
+ ):
772
+ raise httpx.RequestError(
773
+ "Missing Authorization header for Amazon Ads API request.",
774
+ request=request,
775
+ )
776
+ if not (
777
+ auth_headers.get("Amazon-Advertising-API-ClientId")
778
+ or request.headers.get("Amazon-Advertising-API-ClientId")
779
+ ):
780
+ raise httpx.RequestError(
781
+ "Missing ClientId header. Set AMAZON_AD_API_CLIENT_ID (preferred) or AMAZON_ADS_CLIENT_ID, or ensure provider supplies ClientId.",
782
+ request=request,
783
+ )
784
+
785
+ # Profiles endpoint: do not inject scope on root listing
786
+ if path.startswith("/v2/profiles") and path.strip("/") == "v2/profiles":
787
+ for k in [
788
+ "Amazon-Advertising-API-Scope",
789
+ "Amazon-Ads-AccountId",
790
+ ]:
791
+ auth_headers.pop(k, None)
792
+
793
+ # Merge auth headers last
794
+ logger.info("Adding auth headers to request:")
795
+ for k, v in auth_headers.items():
796
+ if v:
797
+ request.headers[k] = v
798
+ if "authorization" in k.lower():
799
+ # Log Bearer prefix and token length for debugging
800
+ if v.startswith("Bearer "):
801
+ token_len = len(v) - 7 # Subtract "Bearer " length
802
+ logger.info(f" {k}: Bearer [token: {token_len} chars]")
803
+ else:
804
+ logger.warning(f" {k}: MISSING 'Bearer ' prefix! Value starts with: {v[:10]}...")
805
+ else:
806
+ logger.info(f" {k}: {v}")
807
+
808
+ # Log final headers (for debugging)
809
+ logger.debug("Final request headers:")
810
+ for k, v in request.headers.items():
811
+ if "authorization" in k.lower() or "token" in k.lower():
812
+ logger.debug(f" {k}: [REDACTED]")
813
+ else:
814
+ logger.debug(f" {k}: {v}")
815
+
816
+ # Ensure Accept header for JSON responses
817
+ if "Accept" not in request.headers:
818
+ request.headers["Accept"] = "application/json"
819
+
820
+
821
+ # Export global state accessors for routing tools
822
+ def get_region_override() -> Optional[str]:
823
+ """Get the current region override.
824
+
825
+ Returns the currently set region override from context-local storage.
826
+ The override affects endpoint routing for Amazon Ads API requests.
827
+
828
+ :return: Current region override ("na", "eu", "fe") or None
829
+ :rtype: Optional[str]
830
+
831
+ .. example::
832
+ >>> region = get_region_override()
833
+ >>> if region:
834
+ ... print(f"Current region override: {region}")
835
+ """
836
+ return _REGION_OVERRIDE_VAR.get()
837
+
838
+
839
+ def set_region_override(region: Optional[str]) -> None:
840
+ """Set the region override.
841
+
842
+ Sets a region override that affects endpoint routing for Amazon Ads API
843
+ requests. Valid values are "na", "eu", "fe", or None to clear.
844
+
845
+ :param region: Region code ("na", "eu", "fe") or None to clear
846
+ :type region: Optional[str]
847
+
848
+ .. example::
849
+ >>> set_region_override("eu") # Route to EU endpoints
850
+ >>> set_region_override(None) # Clear override
851
+ """
852
+ _REGION_OVERRIDE_VAR.set(region)
853
+
854
+
855
+ # Marketplace override functions removed - deprecated functionality
856
+ # Use set_active_region() from tools/region.py instead
857
+
858
+
859
+ def get_routing_state() -> Dict[str, Any]:
860
+ """Get the current routing state.
861
+
862
+ Returns the complete routing state including region, source, host,
863
+ and marketplace information from the last request processed.
864
+
865
+ :return: Dictionary containing routing state information
866
+ :rtype: Dict[str, Any]
867
+
868
+ .. example::
869
+ >>> state = get_routing_state()
870
+ >>> print(f"Region: {state.get('region')}")
871
+ >>> print(f"Host: {state.get('host')}")
872
+ """
873
+ return _ROUTING_STATE_VAR.get()