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,598 @@
1
+ """OAuth tools for integrated authentication flow."""
2
+
3
+ import logging
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Dict, Optional
6
+
7
+ import httpx
8
+ from fastmcp import Context
9
+ from pydantic import BaseModel, Field
10
+
11
+ from ..auth.oauth_state_store import get_oauth_state_store
12
+ from ..config.settings import Settings
13
+ from ..utils.region_config import RegionConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class OAuthState(BaseModel):
19
+ """OAuth state tracking."""
20
+
21
+ state: str = Field(description="OAuth state parameter for CSRF protection")
22
+ auth_url: str = Field(description="Full authorization URL")
23
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
24
+ expires_at: datetime = Field(
25
+ default_factory=lambda: datetime.now(timezone.utc) + timedelta(minutes=10)
26
+ )
27
+ completed: bool = Field(default=False)
28
+
29
+
30
+ class OAuthTokens(BaseModel):
31
+ """OAuth token storage."""
32
+
33
+ access_token: str
34
+ refresh_token: str
35
+ expires_in: int
36
+ obtained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
37
+
38
+ @property
39
+ def is_expired(self) -> bool:
40
+ """Check if access token is expired."""
41
+ expiry = self.obtained_at + timedelta(
42
+ seconds=self.expires_in - 60
43
+ ) # 60s buffer
44
+ return datetime.now(timezone.utc) > expiry
45
+
46
+
47
+ class OAuthTools:
48
+ """OAuth authentication tools for Amazon Ads API."""
49
+
50
+ def __init__(self, settings: Settings):
51
+ self.settings = settings
52
+ self.client_id = settings.ad_api_client_id
53
+ self.client_secret = settings.ad_api_client_secret
54
+ self.region = settings.amazon_ads_region
55
+ # Use PORT env var (set at runtime) or settings.mcp_server_port or default to 9080
56
+ import os
57
+
58
+ port = os.getenv("PORT") or getattr(settings, "mcp_server_port", None) or 9080
59
+ self.redirect_uri = f"http://localhost:{port}/auth/callback"
60
+
61
+ async def start_oauth_flow(
62
+ self,
63
+ ctx: Context,
64
+ user_agent: Optional[str] = None,
65
+ ip_address: Optional[str] = None,
66
+ ) -> Dict:
67
+ """
68
+ Start the OAuth authorization flow.
69
+
70
+ Returns the authorization URL for the user to visit.
71
+ """
72
+ # Get secure state store
73
+ state_store = get_oauth_state_store()
74
+
75
+ # Build base authorization URL
76
+ base_auth_url = (
77
+ f"https://www.amazon.com/ap/oa"
78
+ f"?client_id={self.client_id}"
79
+ f"&scope=cpc_advertising:campaign_management"
80
+ f"&response_type=code"
81
+ f"&redirect_uri={self.redirect_uri}"
82
+ )
83
+
84
+ # Generate secure state with HMAC signature
85
+ state = state_store.generate_state(
86
+ auth_url=base_auth_url,
87
+ user_agent=user_agent,
88
+ ip_address=ip_address,
89
+ ttl_minutes=10,
90
+ )
91
+
92
+ # Add state to auth URL
93
+ auth_url = f"{base_auth_url}&state={state}"
94
+
95
+ # Store OAuth state in context for status tracking
96
+ oauth_state = OAuthState(
97
+ state="[REDACTED]", # Security: don't log OAuth state
98
+ auth_url=auth_url,
99
+ expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
100
+ )
101
+ ctx.set_state("oauth_state", oauth_state.model_dump())
102
+
103
+ return {
104
+ "status": "success",
105
+ "auth_url": auth_url,
106
+ "message": "Visit the URL to authorize. The server will automatically handle the callback.",
107
+ "expires_in_minutes": 10,
108
+ }
109
+
110
+ async def check_oauth_status(self, ctx: Context) -> Dict:
111
+ """
112
+ Check the current OAuth authentication status.
113
+
114
+ Returns whether authentication is complete and token status.
115
+ """
116
+ # First check context for tokens
117
+ tokens_data = ctx.get_state("oauth_tokens")
118
+
119
+ # If not in context, check persistent stores
120
+ if not tokens_data:
121
+ # Try secure token store first
122
+ try:
123
+ from ..auth.secure_token_store import get_secure_token_store
124
+
125
+ secure_store = get_secure_token_store()
126
+
127
+ refresh_entry = secure_store.get_token("oauth_refresh_token")
128
+ access_entry = secure_store.get_token("oauth_access_token")
129
+
130
+ if refresh_entry:
131
+ # Found tokens in secure store - reconstruct token object
132
+ tokens_data = {
133
+ "refresh_token": refresh_entry["value"],
134
+ "access_token": (access_entry["value"] if access_entry else ""),
135
+ "expires_in": 3600,
136
+ "obtained_at": refresh_entry.get(
137
+ "created_at", datetime.now(timezone.utc)
138
+ ).isoformat(),
139
+ }
140
+ # Cache in context for this request
141
+ ctx.set_state("oauth_tokens", tokens_data)
142
+ except Exception as e:
143
+ logger.debug(f"Could not check secure store: {e}")
144
+
145
+ # If still not found, check auth manager's token store
146
+ if not tokens_data:
147
+ try:
148
+ from ..auth.manager import get_auth_manager
149
+ from ..auth.token_store import TokenKind
150
+
151
+ auth_manager = get_auth_manager()
152
+ if auth_manager:
153
+ token_entry = await auth_manager.get_token(
154
+ provider_type="direct",
155
+ identity_id="direct-auth",
156
+ token_kind=TokenKind.REFRESH,
157
+ )
158
+ if token_entry:
159
+ # Found tokens - create minimal token data
160
+ tokens_data = {
161
+ "refresh_token": token_entry.value,
162
+ "access_token": "",
163
+ "expires_in": 0,
164
+ "obtained_at": datetime.now(timezone.utc).isoformat(),
165
+ }
166
+ # Cache in context
167
+ ctx.set_state("oauth_tokens", tokens_data)
168
+ except Exception as e:
169
+ logger.debug(f"Could not check auth manager: {e}")
170
+
171
+ # Check if callback has been received (legacy path)
172
+ if hasattr(self, "_callback_tokens"):
173
+ tokens = self._callback_tokens
174
+ # Store in context for future use
175
+ oauth_tokens = OAuthTokens(
176
+ access_token=tokens["access_token"],
177
+ refresh_token=tokens["refresh_token"],
178
+ expires_in=tokens["expires_in"],
179
+ )
180
+ ctx.set_state("oauth_tokens", oauth_tokens.model_dump())
181
+
182
+ # Clear the callback tokens after storing
183
+ delattr(self, "_callback_tokens")
184
+
185
+ return {
186
+ "authenticated": True,
187
+ "status": "callback_received",
188
+ "message": "Successfully authenticated via OAuth callback",
189
+ "has_refresh_token": True,
190
+ "scope": tokens["scope"],
191
+ }
192
+
193
+ # Check if we found tokens
194
+ if tokens_data:
195
+ # Tokens exist - user is authenticated
196
+ tokens = OAuthTokens(**tokens_data)
197
+ return {
198
+ "authenticated": True,
199
+ "status": "active",
200
+ "has_refresh_token": bool(tokens.refresh_token),
201
+ "access_token_expired": tokens.is_expired,
202
+ "token_age_minutes": int(
203
+ (datetime.now(timezone.utc) - tokens.obtained_at).total_seconds()
204
+ / 60
205
+ ),
206
+ }
207
+ else:
208
+ # No tokens found - check OAuth flow state
209
+ oauth_state = ctx.get_state("oauth_state")
210
+ if oauth_state:
211
+ state_obj = OAuthState(**oauth_state)
212
+ if state_obj.completed:
213
+ return {
214
+ "authenticated": False,
215
+ "status": "error",
216
+ "message": "OAuth flow completed but tokens not stored",
217
+ }
218
+ elif datetime.now(timezone.utc) > state_obj.expires_at:
219
+ return {
220
+ "authenticated": False,
221
+ "status": "expired",
222
+ "message": "OAuth flow expired. Please start again.",
223
+ }
224
+ else:
225
+ return {
226
+ "authenticated": False,
227
+ "status": "pending",
228
+ "message": "Waiting for authorization. Visit the auth URL.",
229
+ "auth_url": state_obj.auth_url,
230
+ }
231
+ else:
232
+ return {
233
+ "authenticated": False,
234
+ "status": "not_started",
235
+ "message": "OAuth flow not started. Use start_oauth_flow first.",
236
+ }
237
+
238
+ async def refresh_access_token(self, ctx: Context) -> Dict:
239
+ """
240
+ Manually refresh the access token using the stored refresh token.
241
+
242
+ This is usually handled automatically by middleware.
243
+ """
244
+ # Try multiple sources for refresh token
245
+ refresh_token = None
246
+
247
+ # 1. Check context state (request-scoped)
248
+ tokens_data = ctx.get_state("oauth_tokens")
249
+ if tokens_data:
250
+ tokens = OAuthTokens(**tokens_data)
251
+ refresh_token = tokens.refresh_token
252
+
253
+ # 2. Check secure token store
254
+ if not refresh_token:
255
+ try:
256
+ from ..auth.secure_token_store import get_secure_token_store
257
+
258
+ secure_store = get_secure_token_store()
259
+ token_entry = secure_store.get_token("oauth_refresh_token")
260
+ if token_entry:
261
+ refresh_token = token_entry["value"]
262
+ except Exception as e:
263
+ logger.debug(f"Could not get from secure store: {e}")
264
+
265
+ # 3. Check callback tokens
266
+ if not refresh_token and hasattr(self, "_callback_tokens"):
267
+ refresh_token = self._callback_tokens.get("refresh_token")
268
+
269
+ # 4. Check auth manager's token store
270
+ if not refresh_token:
271
+ try:
272
+ from ..auth.manager import get_auth_manager
273
+ from ..auth.token_store import TokenKind
274
+
275
+ auth_manager = get_auth_manager()
276
+ if auth_manager:
277
+ token_entry = await auth_manager.get_token(
278
+ provider_type="direct",
279
+ identity_id="direct-auth",
280
+ token_kind=TokenKind.REFRESH,
281
+ )
282
+ if token_entry:
283
+ refresh_token = token_entry.value
284
+ except Exception as e:
285
+ logger.debug(f"Could not get token from auth manager: {e}")
286
+
287
+ if not refresh_token:
288
+ return {
289
+ "status": "error",
290
+ "message": "No refresh token found. Please complete OAuth flow first.",
291
+ }
292
+
293
+ # Create a temporary tokens object if we didn't have one
294
+ if not tokens_data:
295
+ tokens = OAuthTokens(
296
+ access_token="",
297
+ refresh_token=refresh_token,
298
+ expires_in=0,
299
+ obtained_at=datetime.now(timezone.utc),
300
+ )
301
+
302
+ # Exchange refresh token for new access token
303
+ token_url = RegionConfig.get_oauth_endpoint(self.region)
304
+ token_data = {
305
+ "grant_type": "refresh_token",
306
+ "refresh_token": tokens.refresh_token,
307
+ "client_id": self.client_id,
308
+ "client_secret": self.client_secret,
309
+ }
310
+
311
+ # Use explicit timeout for OAuth token refresh
312
+ timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
313
+ async with httpx.AsyncClient(timeout=timeout) as client:
314
+ response = await client.post(token_url, data=token_data)
315
+
316
+ if response.status_code == 200:
317
+ token_response = response.json()
318
+
319
+ # Update tokens
320
+ tokens.access_token = token_response["access_token"]
321
+ tokens.expires_in = token_response.get("expires_in", 3600)
322
+ tokens.obtained_at = datetime.now(timezone.utc)
323
+
324
+ # If a new refresh token was provided, update it
325
+ if "refresh_token" in token_response:
326
+ tokens.refresh_token = token_response["refresh_token"]
327
+
328
+ # Store updated tokens in context
329
+ ctx.set_state("oauth_tokens", tokens.model_dump())
330
+
331
+ # Store updated tokens securely
332
+ try:
333
+ from ..auth.secure_token_store import get_secure_token_store
334
+
335
+ secure_store = get_secure_token_store()
336
+ from datetime import timedelta
337
+
338
+ secure_store.store_token(
339
+ token_id="oauth_refresh_token",
340
+ token_value=tokens.refresh_token,
341
+ token_type="refresh",
342
+ expires_at=datetime.now(timezone.utc) + timedelta(days=365),
343
+ )
344
+
345
+ logger.info("Updated refresh token in secure store")
346
+ except Exception as e:
347
+ logger.warning(f"Could not update secure store: {e}")
348
+
349
+ # Update auth manager's token store
350
+ try:
351
+ from datetime import timedelta
352
+
353
+ from ..auth.manager import get_auth_manager
354
+ from ..auth.token_store import TokenKind
355
+
356
+ auth_manager = get_auth_manager()
357
+ if auth_manager:
358
+ # Store the new access token
359
+ expires_at = tokens.obtained_at + timedelta(
360
+ seconds=tokens.expires_in
361
+ )
362
+ await auth_manager.set_token(
363
+ provider_type="direct",
364
+ identity_id="direct-auth",
365
+ token_kind=TokenKind.ACCESS,
366
+ token=tokens.access_token,
367
+ expires_at=expires_at,
368
+ metadata={"token_type": "Bearer"},
369
+ )
370
+
371
+ # Update refresh token if changed
372
+ if "refresh_token" in token_response:
373
+ await auth_manager.set_token(
374
+ provider_type="direct",
375
+ identity_id="direct-auth",
376
+ token_kind=TokenKind.REFRESH,
377
+ token=tokens.refresh_token,
378
+ expires_at=datetime.now(timezone.utc) + timedelta(days=365),
379
+ metadata={},
380
+ )
381
+
382
+ logger.info("Updated tokens in auth manager store")
383
+ except Exception as e:
384
+ logger.warning(f"Could not update auth manager tokens: {e}")
385
+
386
+ return {
387
+ "status": "success",
388
+ "message": "Access token refreshed successfully",
389
+ "expires_in_seconds": tokens.expires_in,
390
+ }
391
+ else:
392
+ return {
393
+ "status": "error",
394
+ "message": f"Failed to refresh token: {response.status_code}",
395
+ "error": response.text,
396
+ }
397
+
398
+ async def clear_oauth_tokens(self, ctx: Context) -> Dict:
399
+ """
400
+ Clear stored OAuth tokens and state.
401
+
402
+ Use this to reset authentication or switch accounts.
403
+ """
404
+ ctx.set_state("oauth_tokens", None)
405
+ ctx.set_state("oauth_state", None)
406
+
407
+ return {
408
+ "status": "success",
409
+ "message": "OAuth tokens and state cleared. Please run start_oauth_flow to authenticate again.",
410
+ }
411
+
412
+ async def handle_oauth_callback(
413
+ self,
414
+ code: str,
415
+ state: str,
416
+ ctx: Context,
417
+ user_agent: Optional[str] = None,
418
+ ip_address: Optional[str] = None,
419
+ ) -> Dict:
420
+ """
421
+ Handle the OAuth callback from Amazon.
422
+
423
+ This is called internally by the server when Amazon redirects back.
424
+ """
425
+ # Validate state using secure store
426
+ state_store = get_oauth_state_store()
427
+ is_valid, error_message = state_store.validate_state(
428
+ state=state, user_agent=user_agent, ip_address=ip_address
429
+ )
430
+
431
+ if not is_valid:
432
+ logger.warning(f"OAuth state validation failed: {error_message}")
433
+ return {
434
+ "status": "error",
435
+ "message": error_message or "Invalid state parameter",
436
+ }
437
+
438
+ # Exchange code for tokens
439
+ token_url = RegionConfig.get_oauth_endpoint(self.region)
440
+ token_data = {
441
+ "grant_type": "authorization_code",
442
+ "code": code,
443
+ "redirect_uri": self.redirect_uri,
444
+ "client_id": self.client_id,
445
+ "client_secret": self.client_secret,
446
+ }
447
+
448
+ # Use explicit timeout for OAuth callback token exchange
449
+ timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
450
+ async with httpx.AsyncClient(timeout=timeout) as client:
451
+ response = await client.post(token_url, data=token_data)
452
+
453
+ if response.status_code == 200:
454
+ token_response = response.json()
455
+
456
+ # Store tokens
457
+ tokens = OAuthTokens(
458
+ access_token=token_response["access_token"],
459
+ refresh_token=token_response.get("refresh_token", ""),
460
+ expires_in=token_response.get("expires_in", 3600),
461
+ )
462
+
463
+ ctx.set_state("oauth_tokens", tokens.model_dump())
464
+
465
+ # Mark OAuth state as completed
466
+ oauth_state = ctx.get_state("oauth_state")
467
+ if oauth_state:
468
+ oauth_state["completed"] = True
469
+ ctx.set_state("oauth_state", oauth_state)
470
+
471
+ # Store the refresh token securely
472
+ try:
473
+ from ..auth.secure_token_store import get_secure_token_store
474
+
475
+ secure_store = get_secure_token_store()
476
+ from datetime import datetime, timedelta, timezone
477
+
478
+ secure_store.store_token(
479
+ token_id="oauth_refresh_token",
480
+ token_value=tokens.refresh_token,
481
+ token_type="refresh",
482
+ expires_at=datetime.now(timezone.utc) + timedelta(days=365),
483
+ metadata={"scope": token_response.get("scope")},
484
+ )
485
+
486
+ secure_store.store_token(
487
+ token_id="oauth_access_token",
488
+ token_value=tokens.access_token,
489
+ token_type="access",
490
+ expires_at=tokens.obtained_at
491
+ + timedelta(seconds=tokens.expires_in),
492
+ metadata={"token_type": "Bearer"},
493
+ )
494
+
495
+ logger.info("Stored tokens in secure token store")
496
+ except Exception as e:
497
+ logger.error(f"Failed to store tokens securely: {e}")
498
+ # Continue without raising - tokens are stored in context at minimum
499
+
500
+ # Store tokens in unified token store if auth manager available
501
+ try:
502
+ from datetime import datetime, timedelta, timezone
503
+
504
+ from ..auth.manager import get_auth_manager
505
+ from ..auth.token_store import TokenKind
506
+
507
+ auth_manager = get_auth_manager()
508
+ if auth_manager and hasattr(auth_manager, "set_token"):
509
+ # Store refresh token
510
+ await auth_manager.set_token(
511
+ provider_type="direct",
512
+ identity_id="direct-auth",
513
+ token_kind=TokenKind.REFRESH,
514
+ token=tokens.refresh_token,
515
+ expires_at=datetime.now(timezone.utc)
516
+ + timedelta(days=365), # Long-lived
517
+ metadata={},
518
+ )
519
+
520
+ # Store access token
521
+ expires_at = tokens.obtained_at + timedelta(
522
+ seconds=tokens.expires_in
523
+ )
524
+ await auth_manager.set_token(
525
+ provider_type="direct",
526
+ identity_id="direct-auth",
527
+ token_kind=TokenKind.ACCESS,
528
+ token=tokens.access_token,
529
+ expires_at=expires_at,
530
+ metadata={"token_type": "Bearer"},
531
+ )
532
+ logger.info("Stored OAuth tokens in unified token store")
533
+
534
+ # Update the DirectProvider's refresh token
535
+ if (
536
+ auth_manager.provider
537
+ and auth_manager.provider.provider_type == "direct"
538
+ ):
539
+ auth_manager.provider.refresh_token = tokens.refresh_token
540
+ logger.info("Updated DirectProvider with new refresh token")
541
+ except Exception as e:
542
+ logger.error(f"Could not update auth manager: {e}")
543
+
544
+ return {
545
+ "status": "success",
546
+ "message": "OAuth completed successfully",
547
+ "has_refresh_token": bool(tokens.refresh_token),
548
+ }
549
+ else:
550
+ return {
551
+ "status": "error",
552
+ "message": f"Failed to exchange code: {response.status_code}",
553
+ "error": response.text,
554
+ }
555
+
556
+
557
+ def register_oauth_tools(mcp, settings: Settings):
558
+ """Register OAuth tools with the FastMCP server."""
559
+ oauth = OAuthTools(settings)
560
+
561
+ @mcp.tool
562
+ async def start_oauth_flow(ctx: Context) -> Dict:
563
+ """
564
+ Start the OAuth authorization flow for Amazon Ads API.
565
+
566
+ Returns an authorization URL that the user should visit to grant access.
567
+ The server will automatically handle the callback.
568
+ """
569
+ return await oauth.start_oauth_flow(ctx)
570
+
571
+ @mcp.tool
572
+ async def check_oauth_status(ctx: Context) -> Dict:
573
+ """
574
+ Check the current OAuth authentication status.
575
+
576
+ Returns whether the user is authenticated and token information.
577
+ """
578
+ return await oauth.check_oauth_status(ctx)
579
+
580
+ @mcp.tool
581
+ async def refresh_oauth_token(ctx: Context) -> Dict:
582
+ """
583
+ Manually refresh the OAuth access token.
584
+
585
+ This is usually handled automatically, but can be triggered manually if needed.
586
+ """
587
+ return await oauth.refresh_access_token(ctx)
588
+
589
+ @mcp.tool
590
+ async def clear_oauth_tokens(ctx: Context) -> Dict:
591
+ """
592
+ Clear all stored OAuth tokens and state.
593
+
594
+ Use this to reset authentication or switch to a different account.
595
+ """
596
+ return await oauth.clear_oauth_tokens(ctx)
597
+
598
+ return oauth