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,962 @@
1
+ """Register built-in tools for the MCP server.
2
+
3
+ Handle registration of identity, profile, region, download, sampling, and
4
+ authentication tools depending on the active provider.
5
+
6
+ Examples
7
+ --------
8
+ .. code-block:: python
9
+
10
+ import asyncio
11
+ from fastmcp import FastMCP
12
+ from amazon_ads_mcp.server.builtin_tools import register_all_builtin_tools
13
+
14
+ async def main():
15
+ server = FastMCP("amazon-ads")
16
+ await register_all_builtin_tools(server)
17
+
18
+ asyncio.run(main())
19
+ """
20
+
21
+ import logging
22
+ from typing import Optional
23
+
24
+ from fastmcp import Context, FastMCP
25
+
26
+ from fastmcp.dependencies import Progress
27
+
28
+ from ..auth.manager import get_auth_manager
29
+ from ..config.settings import settings
30
+ from ..models.builtin_responses import (
31
+ AsyncReportResponse,
32
+ ClearProfileResponse,
33
+ DownloadedFile,
34
+ DownloadExportResponse,
35
+ GetDownloadUrlResponse,
36
+ GetProfileResponse,
37
+ GetRegionResponse,
38
+ ListDownloadsResponse,
39
+ ListRegionsResponse,
40
+ ProfileSelectorResponse,
41
+ ProfilePageResponse,
42
+ ProfileCacheRefreshResponse,
43
+ ProfileSearchResponse,
44
+ ProfileSummaryResponse,
45
+ RoutingStateResponse,
46
+ SamplingTestResponse,
47
+ SetProfileResponse,
48
+ SetRegionResponse,
49
+ )
50
+ from ..tools import identity, profile, profile_listing, region
51
+ from ..tools.oauth import OAuthTools
52
+
53
+ # Removed http_client imports - override functions were removed
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ async def register_identity_tools(server: FastMCP):
59
+ """Register identity management tools.
60
+
61
+ :param server: FastMCP server instance.
62
+ """
63
+
64
+ @server.tool(
65
+ name="set_active_identity",
66
+ description="Set the active identity for Amazon Ads API calls",
67
+ )
68
+ async def set_active_identity_tool(
69
+ ctx: Context,
70
+ identity_id: str,
71
+ persist: bool = False,
72
+ ):
73
+ """Set the active identity for API calls."""
74
+ from ..models import SetActiveIdentityRequest
75
+
76
+ req = SetActiveIdentityRequest(
77
+ identity_id=identity_id,
78
+ persist=persist,
79
+ )
80
+ return await identity.set_active_identity(req)
81
+
82
+ @server.tool(
83
+ name="get_active_identity",
84
+ description="Get the currently active identity",
85
+ )
86
+ async def get_active_identity_tool(ctx: Context):
87
+ """Get the currently active identity."""
88
+ return await identity.get_active_identity()
89
+
90
+ @server.tool(name="list_identities", description="List all available identities")
91
+ async def list_identities_tool(ctx: Context) -> dict:
92
+ """List all available identities."""
93
+ return await identity.list_identities()
94
+
95
+
96
+ async def register_profile_tools(server: FastMCP):
97
+ """Register profile management tools.
98
+
99
+ :param server: FastMCP server instance.
100
+ """
101
+
102
+ @server.tool(
103
+ name="set_active_profile",
104
+ description="Set the active profile ID for API calls",
105
+ )
106
+ async def set_active_profile_tool(
107
+ ctx: Context, profile_id: str
108
+ ) -> SetProfileResponse:
109
+ """Set the active profile ID."""
110
+ result = await profile.set_active_profile(profile_id)
111
+ return SetProfileResponse(**result)
112
+
113
+ @server.tool(
114
+ name="get_active_profile",
115
+ description="Get the currently active profile ID",
116
+ )
117
+ async def get_active_profile_tool(ctx: Context) -> GetProfileResponse:
118
+ """Get the currently active profile ID."""
119
+ result = await profile.get_active_profile()
120
+ return GetProfileResponse(**result)
121
+
122
+ @server.tool(name="clear_active_profile", description="Clear the active profile ID")
123
+ async def clear_active_profile_tool(ctx: Context) -> ClearProfileResponse:
124
+ """Clear the active profile ID."""
125
+ result = await profile.clear_active_profile()
126
+ return ClearProfileResponse(**result)
127
+
128
+ @server.tool(
129
+ name="select_profile",
130
+ description="Interactively select a profile from available options",
131
+ )
132
+ async def select_profile_tool(ctx: Context) -> ProfileSelectorResponse:
133
+ """Interactively select an Amazon Ads profile.
134
+
135
+ This tool uses MCP elicitation to present available profiles to the user
136
+ and let them select one interactively. This is more user-friendly than
137
+ requiring users to call list_profiles and set_active_profile separately.
138
+
139
+ The tool will:
140
+ 1. Fetch available profiles from the Amazon Ads API
141
+ 2. Present them to the user via elicitation
142
+ 3. Set the selected profile as active
143
+ 4. Return the selection result
144
+ """
145
+ from dataclasses import dataclass
146
+
147
+ from ..tools import profile_listing
148
+
149
+ # Define the selection structure for elicitation
150
+ @dataclass
151
+ class ProfileSelection:
152
+ profile_id: str
153
+
154
+ # Fetch available profiles
155
+ try:
156
+ profiles_data, stale = await profile_listing.get_profiles_cached()
157
+ except Exception as e:
158
+ logger.error(f"Failed to fetch profiles: {e}")
159
+ return ProfileSelectorResponse(
160
+ success=False,
161
+ action="cancel",
162
+ message=f"Failed to fetch profiles: {e}",
163
+ )
164
+
165
+ if not profiles_data:
166
+ return ProfileSelectorResponse(
167
+ success=False,
168
+ action="cancel",
169
+ message="No profiles available. Please ensure you have access to advertising accounts.",
170
+ )
171
+
172
+ if len(profiles_data) > profile_listing.PROFILE_SELECTION_THRESHOLD:
173
+ message = (
174
+ "Too many profiles to display here. Use summarize_profiles, "
175
+ "search_profiles, or page_profiles to locate the right profile."
176
+ )
177
+ if stale:
178
+ message = "Using cached profile list; data may be stale. " + message
179
+ return ProfileSelectorResponse(
180
+ success=True,
181
+ action="cancel",
182
+ message=message,
183
+ )
184
+
185
+ # Build a formatted message with profile options
186
+ profile_list = []
187
+ for p in profiles_data:
188
+ profile_id = str(p.get("profileId", ""))
189
+ country = p.get("countryCode", "")
190
+ account_info = p.get("accountInfo", {})
191
+ account_name = account_info.get("name", "Unknown")
192
+ account_type = account_info.get("type", "Unknown")
193
+ profile_list.append(
194
+ f" - {profile_id}: {account_name} ({country}, {account_type})"
195
+ )
196
+
197
+ profiles_message = (
198
+ f"Available profiles ({len(profiles_data)} found):\n"
199
+ + "\n".join(profile_list)
200
+ + "\n\nEnter the profile ID you want to use:"
201
+ )
202
+
203
+ # Use elicitation to let user select
204
+ try:
205
+ result = await ctx.elicit(
206
+ message=profiles_message,
207
+ response_type=ProfileSelection,
208
+ )
209
+
210
+ if result.action == "accept":
211
+ selected_id = result.data.profile_id
212
+
213
+ # Validate the selection
214
+ valid_ids = [str(p.get("profileId", "")) for p in profiles_data]
215
+ if selected_id not in valid_ids:
216
+ return ProfileSelectorResponse(
217
+ success=False,
218
+ action="accept",
219
+ message=f"Invalid profile ID: {selected_id}. Please select from the available profiles.",
220
+ )
221
+
222
+ # Set the selected profile as active
223
+ await profile.set_active_profile(selected_id)
224
+
225
+ # Find the profile name for the response
226
+ selected_profile = next(
227
+ (p for p in profiles_data if str(p.get("profileId")) == selected_id),
228
+ None,
229
+ )
230
+ profile_name = (
231
+ selected_profile.get("accountInfo", {}).get("name", "Unknown")
232
+ if selected_profile
233
+ else "Unknown"
234
+ )
235
+
236
+ return ProfileSelectorResponse(
237
+ success=True,
238
+ action="accept",
239
+ profile_id=selected_id,
240
+ profile_name=profile_name,
241
+ message=f"Profile '{profile_name}' ({selected_id}) is now active.",
242
+ )
243
+
244
+ elif result.action == "decline":
245
+ return ProfileSelectorResponse(
246
+ success=True,
247
+ action="decline",
248
+ message="Profile selection declined. No changes made.",
249
+ )
250
+
251
+ else: # cancel
252
+ return ProfileSelectorResponse(
253
+ success=True,
254
+ action="cancel",
255
+ message="Profile selection cancelled.",
256
+ )
257
+
258
+ except Exception as e:
259
+ logger.error(f"Elicitation failed: {e}")
260
+ return ProfileSelectorResponse(
261
+ success=False,
262
+ action="cancel",
263
+ message=f"Profile selection failed: {e}",
264
+ )
265
+
266
+
267
+ async def register_profile_listing_tools(server: FastMCP):
268
+ """Register profile listing tools with bounded responses."""
269
+
270
+ @server.tool(
271
+ name="summarize_profiles",
272
+ description="Summarize available profiles by country and account type",
273
+ )
274
+ async def summarize_profiles_tool(ctx: Context) -> ProfileSummaryResponse:
275
+ """Summarize available profiles."""
276
+ result = await profile_listing.summarize_profiles()
277
+ return ProfileSummaryResponse(**result)
278
+
279
+ @server.tool(
280
+ name="search_profiles",
281
+ description="Search profiles by name, country, or account type",
282
+ )
283
+ async def search_profiles_tool(
284
+ ctx: Context,
285
+ query: Optional[str] = None,
286
+ country_code: Optional[str] = None,
287
+ account_type: Optional[str] = None,
288
+ limit: int = profile_listing.DEFAULT_SEARCH_LIMIT,
289
+ ) -> ProfileSearchResponse:
290
+ """Search profiles with bounded output."""
291
+ result = await profile_listing.search_profiles(
292
+ query=query,
293
+ country_code=country_code,
294
+ account_type=account_type,
295
+ limit=limit,
296
+ )
297
+ return ProfileSearchResponse(**result)
298
+
299
+ @server.tool(
300
+ name="page_profiles",
301
+ description="Page through profiles with offset and limit",
302
+ )
303
+ async def page_profiles_tool(
304
+ ctx: Context,
305
+ country_code: Optional[str] = None,
306
+ account_type: Optional[str] = None,
307
+ offset: int = 0,
308
+ limit: int = profile_listing.DEFAULT_PAGE_LIMIT,
309
+ ) -> ProfilePageResponse:
310
+ """Return a page of profiles with bounded output."""
311
+ result = await profile_listing.page_profiles(
312
+ country_code=country_code,
313
+ account_type=account_type,
314
+ offset=offset,
315
+ limit=limit,
316
+ )
317
+ return ProfilePageResponse(**result)
318
+
319
+ @server.tool(
320
+ name="refresh_profiles_cache",
321
+ description="Force refresh of cached profiles for the current identity and region",
322
+ )
323
+ async def refresh_profiles_cache_tool(ctx: Context) -> ProfileCacheRefreshResponse:
324
+ """Force refresh the cached profile list."""
325
+ result = await profile_listing.refresh_profiles_cache()
326
+ return ProfileCacheRefreshResponse(**result)
327
+
328
+
329
+ async def register_region_tools(server: FastMCP):
330
+ """Register region management tools.
331
+
332
+ :param server: FastMCP server instance.
333
+ """
334
+
335
+ @server.tool(
336
+ name="set_region",
337
+ description="Set the region for Amazon Ads API calls",
338
+ )
339
+ async def set_region_tool(ctx: Context, region_code: str) -> SetRegionResponse:
340
+ """Set the region for API calls."""
341
+ result = await region.set_region(region_code)
342
+ return SetRegionResponse(**result)
343
+
344
+ @server.tool(name="get_region", description="Get the current region setting")
345
+ async def get_region_tool(ctx: Context) -> GetRegionResponse:
346
+ """Get the current region."""
347
+ result = await region.get_region()
348
+ return GetRegionResponse(**result)
349
+
350
+ @server.tool(name="list_regions", description="List all available regions")
351
+ async def list_regions_tool(ctx: Context) -> ListRegionsResponse:
352
+ """List available regions."""
353
+ result = await region.list_regions()
354
+ return ListRegionsResponse(**result)
355
+
356
+ @server.tool(
357
+ name="get_routing_state",
358
+ description="Get the current routing state including region, host, and headers",
359
+ )
360
+ async def get_routing_state_tool(ctx: Context) -> RoutingStateResponse:
361
+ """Get the complete routing state for debugging."""
362
+ from ..utils.http_client import get_routing_state
363
+ from ..utils.region_config import RegionConfig
364
+
365
+ result = get_routing_state()
366
+
367
+ # Provide defaults when no routing state has been established yet
368
+ # (e.g., on a fresh server before any requests)
369
+ current_region = settings.amazon_ads_region or "na"
370
+ default_host = RegionConfig.get_api_host(current_region)
371
+
372
+ # Apply sandbox host replacement (same pattern used in http_client.py)
373
+ if settings.amazon_ads_sandbox_mode:
374
+ default_host = default_host.replace("advertising-api", "advertising-api-test")
375
+
376
+ return RoutingStateResponse(
377
+ region=result.get("region", current_region),
378
+ host=result.get("host", default_host),
379
+ headers=result.get("headers", {}),
380
+ sandbox=settings.amazon_ads_sandbox_mode,
381
+ )
382
+
383
+
384
+ # Removed region_identity_tools - list_identities_by_region was just a convenience wrapper
385
+
386
+
387
+ # Routing override tools removed - use the main region/marketplace tools instead
388
+
389
+
390
+ async def register_download_tools(server: FastMCP):
391
+ """Register download management tools.
392
+
393
+ :param server: FastMCP server instance
394
+ :type server: FastMCP
395
+ """
396
+
397
+ # Background task with progress reporting for long-running downloads
398
+ # task=True enables MCP background task protocol (SEP-1686) in FastMCP 2.14+
399
+ @server.tool(
400
+ name="download_export",
401
+ description="Download a completed export to local storage (supports background execution)",
402
+ task=True, # Enable background task execution
403
+ )
404
+ async def download_export_tool(
405
+ ctx: Context,
406
+ export_id: str,
407
+ export_url: str,
408
+ progress: Progress = Progress(), # Inject progress tracker
409
+ ) -> DownloadExportResponse:
410
+ """Download a completed export to local storage.
411
+
412
+ This tool supports background execution with progress reporting.
413
+ When called with task=True by the client, it returns immediately
414
+ with a task ID while the download continues in the background.
415
+ """
416
+ import base64
417
+
418
+ from ..utils.export_download_handler import get_download_handler
419
+
420
+ # Report progress: starting download
421
+ await progress.set_total(3) # 3 steps: parse, download, complete
422
+ await progress.set_message("Parsing export metadata...")
423
+ await progress.increment()
424
+
425
+ handler = get_download_handler()
426
+
427
+ # Get active profile for scoped storage
428
+ auth_mgr = get_auth_manager()
429
+ profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
430
+
431
+ # Determine export type from ID
432
+ try:
433
+ padded = export_id + "=" * (4 - len(export_id) % 4)
434
+ decoded = base64.b64decode(padded).decode("utf-8")
435
+ if "," in decoded:
436
+ _, suffix = decoded.rsplit(",", 1)
437
+ type_map = {
438
+ "C": "campaigns",
439
+ "A": "adgroups",
440
+ "AD": "ads",
441
+ "T": "targets",
442
+ }
443
+ export_type = type_map.get(suffix.upper(), "general")
444
+ else:
445
+ export_type = "general"
446
+ except (AttributeError, TypeError, ValueError):
447
+ export_type = "general"
448
+
449
+ # Report progress: downloading
450
+ await progress.set_message(f"Downloading {export_type} export...")
451
+ await progress.increment()
452
+
453
+ file_path = await handler.download_export(
454
+ export_url=export_url,
455
+ export_id=export_id,
456
+ export_type=export_type,
457
+ profile_id=profile_id,
458
+ )
459
+
460
+ # Report progress: complete
461
+ await progress.set_message("Download complete!")
462
+ await progress.increment()
463
+
464
+ return DownloadExportResponse(
465
+ success=True,
466
+ file_path=str(file_path),
467
+ export_type=export_type,
468
+ message=f"Export downloaded to {file_path}",
469
+ )
470
+
471
+ @server.tool(
472
+ name="list_downloads",
473
+ description="List all downloaded exports and reports for the active profile",
474
+ )
475
+ async def list_downloads_tool(
476
+ ctx: Context, resource_type: Optional[str] = None
477
+ ) -> ListDownloadsResponse:
478
+ """List downloaded files for the active profile."""
479
+ from ..tools.download_tools import list_downloaded_files
480
+
481
+ # Get active profile for scoped listing
482
+ auth_mgr = get_auth_manager()
483
+ profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
484
+
485
+ result = await list_downloaded_files(resource_type, profile_id=profile_id)
486
+
487
+ # Transform flat file list into DownloadedFile objects
488
+ files = []
489
+ for f in result.get("files", []):
490
+ # Extract resource_type from path (e.g., "exports/campaigns/file.json")
491
+ path_parts = f.get("path", "").split("/")
492
+ rtype = path_parts[0] if path_parts else "unknown"
493
+
494
+ files.append(
495
+ DownloadedFile(
496
+ filename=f.get("name", ""),
497
+ path=f.get("path", ""),
498
+ size=f.get("size", 0),
499
+ modified=f.get("modified", ""),
500
+ resource_type=rtype,
501
+ )
502
+ )
503
+
504
+ return ListDownloadsResponse(
505
+ success=True,
506
+ files=files,
507
+ count=result.get("total_files", len(files)),
508
+ download_dir=result.get("base_directory", ""),
509
+ )
510
+
511
+ @server.tool(
512
+ name="get_download_url",
513
+ description="""Get the HTTP URL for downloading a file.
514
+
515
+ Use with list_downloads to find available files, then get their download URLs.
516
+ The URL can be opened in a browser or used with curl/wget to download the file.
517
+
518
+ Note: Requires HTTP transport (not stdio).
519
+ """,
520
+ )
521
+ async def get_download_url_tool(
522
+ ctx: Context,
523
+ file_path: str,
524
+ ) -> GetDownloadUrlResponse:
525
+ """Generate the download URL for a file.
526
+
527
+ :param ctx: MCP context
528
+ :param file_path: Relative path from list_downloads output
529
+ :return: Response with download URL
530
+ """
531
+ from pathlib import Path
532
+ from urllib.parse import quote
533
+
534
+ # Try to get HTTP request context
535
+ try:
536
+ from fastmcp.server.dependencies import get_http_request
537
+
538
+ request = get_http_request()
539
+ except (ImportError, RuntimeError):
540
+ return GetDownloadUrlResponse(
541
+ success=False,
542
+ error="HTTP transport required for file downloads",
543
+ hint="Run server with --transport http",
544
+ )
545
+
546
+ # Get current profile
547
+ auth_mgr = get_auth_manager()
548
+ profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
549
+
550
+ if not profile_id:
551
+ return GetDownloadUrlResponse(
552
+ success=False,
553
+ error="No active profile",
554
+ hint="Set active profile before getting download URLs",
555
+ )
556
+
557
+ # Validate file exists
558
+ from ..utils.export_download_handler import get_download_handler
559
+
560
+ handler = get_download_handler()
561
+ profile_dir = handler.base_dir / "profiles" / profile_id
562
+ full_path = profile_dir / file_path
563
+
564
+ if not full_path.exists():
565
+ return GetDownloadUrlResponse(
566
+ success=False,
567
+ error="File not found",
568
+ hint="Use list_downloads to see available files",
569
+ )
570
+
571
+ # Build URL with proper encoding
572
+ base_url = str(request.base_url).rstrip("/")
573
+
574
+ # Handle forwarded headers from reverse proxy
575
+ forwarded_proto = request.headers.get("X-Forwarded-Proto")
576
+ forwarded_host = request.headers.get("X-Forwarded-Host")
577
+ if forwarded_proto and forwarded_host:
578
+ base_url = f"{forwarded_proto}://{forwarded_host}"
579
+
580
+ # URL-encode path segments
581
+ encoded_path = "/".join(
582
+ quote(part, safe="") for part in Path(file_path).parts
583
+ )
584
+
585
+ download_url = f"{base_url}/downloads/{encoded_path}"
586
+ return GetDownloadUrlResponse(
587
+ success=True,
588
+ download_url=download_url,
589
+ file_name=full_path.name,
590
+ size_bytes=full_path.stat().st_size,
591
+ profile_id=profile_id,
592
+ instructions=(
593
+ f"Use HTTP GET to download: curl -O '{download_url}'. "
594
+ "If authentication is enabled, add header: "
595
+ "Authorization: Bearer <token>"
596
+ ),
597
+ )
598
+
599
+
600
+ async def register_reporting_tools(server: FastMCP):
601
+ """Register reporting workflow tools with background task support.
602
+
603
+ These wrapper tools orchestrate the full report workflow (request → poll → download)
604
+ with progress tracking. OpenAPI-generated tools cannot have task=True, so we
605
+ create builtin wrappers for long-running operations.
606
+
607
+ :param server: FastMCP server instance
608
+ :type server: FastMCP
609
+ """
610
+
611
+ @server.tool(
612
+ name="request_async_report",
613
+ description="Request and download an async report with progress tracking (V3 Reporting API)",
614
+ task=True, # Enable background task execution
615
+ )
616
+ async def request_async_report_tool(
617
+ ctx: Context,
618
+ report_type: str,
619
+ start_date: str,
620
+ end_date: str,
621
+ time_unit: str = "DAILY",
622
+ group_by: Optional[str] = None,
623
+ columns: Optional[str] = None,
624
+ filters: Optional[str] = None,
625
+ poll_interval_seconds: int = 10,
626
+ max_poll_attempts: int = 60,
627
+ progress: Progress = Progress(),
628
+ ) -> AsyncReportResponse:
629
+ """Request an async report and wait for completion with progress tracking.
630
+
631
+ This tool handles the full V3 Reporting API workflow:
632
+ 1. Creates a report request
633
+ 2. Polls for completion with progress updates
634
+ 3. Downloads the completed report
635
+ 4. Returns the local file path
636
+
637
+ Supports background execution - clients can track progress while report
638
+ generates in the background.
639
+
640
+ :param report_type: Report type (e.g., spCampaigns, spTargeting, sbCampaigns)
641
+ :param start_date: Report start date (YYYY-MM-DD)
642
+ :param end_date: Report end date (YYYY-MM-DD)
643
+ :param time_unit: Time granularity (DAILY, SUMMARY)
644
+ :param group_by: Comma-separated list of dimensions to group by
645
+ :param columns: Comma-separated list of columns to include
646
+ :param filters: JSON string of filters to apply
647
+ :param poll_interval_seconds: Seconds between status checks (default: 10)
648
+ :param max_poll_attempts: Maximum polling attempts before timeout (default: 60)
649
+ :return: Report result with file path
650
+ """
651
+ import asyncio
652
+ import json as json_module
653
+
654
+ from ..utils.http_client import get_authenticated_client
655
+
656
+ # Total steps: create (1) + poll (variable) + download (1)
657
+ await progress.set_total(max_poll_attempts + 2)
658
+ await progress.set_message("Creating report request...")
659
+ await progress.increment()
660
+
661
+ # Get HTTP client for API calls
662
+ client = await get_authenticated_client()
663
+
664
+ # Build report request body
665
+ report_config = {
666
+ "reportType": report_type,
667
+ "startDate": start_date,
668
+ "endDate": end_date,
669
+ "timeUnit": time_unit,
670
+ "format": "GZIP_JSON",
671
+ }
672
+
673
+ if group_by:
674
+ report_config["groupBy"] = [g.strip() for g in group_by.split(",")]
675
+ if columns:
676
+ report_config["columns"] = [c.strip() for c in columns.split(",")]
677
+ if filters:
678
+ try:
679
+ report_config["filters"] = json_module.loads(filters)
680
+ except json_module.JSONDecodeError:
681
+ return AsyncReportResponse(
682
+ success=False, error="Invalid JSON in filters parameter"
683
+ )
684
+
685
+ # Create report request
686
+ try:
687
+ response = await client.post("/reporting/reports", json=report_config)
688
+ response.raise_for_status()
689
+ create_result = response.json()
690
+ report_id = create_result.get("reportId")
691
+
692
+ if not report_id:
693
+ return AsyncReportResponse(
694
+ success=False, error="No reportId in response"
695
+ )
696
+
697
+ except Exception as e:
698
+ return AsyncReportResponse(
699
+ success=False, error=f"Failed to create report: {e}"
700
+ )
701
+
702
+ await progress.set_message(f"Report created: {report_id}")
703
+
704
+ # Poll for completion
705
+ download_url = None
706
+ for attempt in range(max_poll_attempts):
707
+ await progress.set_message(f"Checking status... (attempt {attempt + 1})")
708
+ await progress.increment()
709
+
710
+ try:
711
+ status_response = await client.get(f"/reporting/reports/{report_id}")
712
+ status_response.raise_for_status()
713
+ status_data = status_response.json()
714
+
715
+ status = status_data.get("status", "UNKNOWN")
716
+
717
+ if status == "COMPLETED":
718
+ download_url = status_data.get("url")
719
+ break
720
+ elif status == "FAILED":
721
+ error_details = status_data.get("failureReason", "Unknown error")
722
+ return AsyncReportResponse(
723
+ success=False,
724
+ report_id=report_id,
725
+ status=status,
726
+ error=f"Report failed: {error_details}",
727
+ )
728
+ elif status in ("PENDING", "PROCESSING", "IN_PROGRESS"):
729
+ await asyncio.sleep(poll_interval_seconds)
730
+ else:
731
+ await asyncio.sleep(poll_interval_seconds)
732
+
733
+ except Exception as e:
734
+ logger.warning(f"Error checking report status: {e}")
735
+ await asyncio.sleep(poll_interval_seconds)
736
+
737
+ if not download_url:
738
+ return AsyncReportResponse(
739
+ success=False,
740
+ report_id=report_id,
741
+ error=f"Report did not complete within {max_poll_attempts * poll_interval_seconds} seconds",
742
+ )
743
+
744
+ # Download the report
745
+ await progress.set_message("Downloading report...")
746
+ await progress.increment()
747
+
748
+ try:
749
+ from ..utils.export_download_handler import get_download_handler
750
+
751
+ handler = get_download_handler()
752
+
753
+ # Get active profile for scoped storage
754
+ auth_mgr = get_auth_manager()
755
+ profile_id = auth_mgr.get_active_profile_id() if auth_mgr else None
756
+
757
+ file_path = await handler.download_export(
758
+ export_url=download_url,
759
+ export_id=report_id,
760
+ export_type=f"report_{report_type}",
761
+ metadata={"report_config": report_config},
762
+ profile_id=profile_id,
763
+ )
764
+
765
+ await progress.set_message("Report download complete!")
766
+
767
+ return AsyncReportResponse(
768
+ success=True,
769
+ report_id=report_id,
770
+ report_type=report_type,
771
+ file_path=str(file_path),
772
+ message=f"Report downloaded to {file_path}",
773
+ )
774
+
775
+ except Exception as e:
776
+ return AsyncReportResponse(
777
+ success=False,
778
+ report_id=report_id,
779
+ error=f"Failed to download report: {e}",
780
+ download_url=download_url,
781
+ )
782
+
783
+ logger.info("Registered reporting workflow tools with background task support")
784
+
785
+
786
+ async def register_sampling_tools(server: FastMCP):
787
+ """Register sampling tools if sampling is enabled.
788
+
789
+ :param server: FastMCP server instance
790
+ :type server: FastMCP
791
+ """
792
+ if not settings.enable_sampling:
793
+ return
794
+
795
+ @server.tool(
796
+ name="test_sampling",
797
+ description="Test LLM sampling functionality via MCP client",
798
+ )
799
+ async def test_sampling_tool(
800
+ ctx: Context,
801
+ message: str = "Hello, please summarize this test message",
802
+ ) -> SamplingTestResponse:
803
+ """Test the native MCP sampling functionality.
804
+
805
+ Uses FastMCP 2.14.1+ native ctx.sample() directly. This requires
806
+ the MCP client to support sampling (createMessage capability).
807
+
808
+ The sampling flow:
809
+ 1. Tool sends sampling request to client via ctx.sample()
810
+ 2. Client's LLM generates a response
811
+ 3. Response is returned to the tool
812
+
813
+ Note: If the client doesn't support sampling, an error will be returned.
814
+ Server-side fallback is available when SAMPLING_ENABLED=true and
815
+ OPENAI_API_KEY is configured.
816
+ """
817
+ try:
818
+ # Use native ctx.sample() - FastMCP 2.14.1+
819
+ result = await ctx.sample(
820
+ messages=message,
821
+ system_prompt="You are a helpful assistant. Provide a brief summary.",
822
+ temperature=0.7,
823
+ max_tokens=100,
824
+ )
825
+
826
+ # Extract text from result
827
+ response_text = result.text if hasattr(result, "text") else str(result)
828
+
829
+ return SamplingTestResponse(
830
+ success=True,
831
+ message="Sampling executed successfully via native ctx.sample()",
832
+ response=response_text,
833
+ sampling_enabled=settings.enable_sampling,
834
+ )
835
+ except Exception as e:
836
+ error_msg = str(e).lower()
837
+
838
+ # Check if it's a "client doesn't support sampling" error
839
+ if "does not support sampling" in error_msg or "sampling not supported" in error_msg:
840
+ # Try server-side fallback if enabled
841
+ if settings.enable_sampling:
842
+ try:
843
+ from ..utils.sampling_helpers import sample_with_fallback
844
+
845
+ result = await sample_with_fallback(
846
+ ctx=ctx,
847
+ messages=message,
848
+ system_prompt="You are a helpful assistant. Provide a brief summary.",
849
+ temperature=0.7,
850
+ max_tokens=100,
851
+ )
852
+ response_text = result.text if hasattr(result, "text") else str(result)
853
+
854
+ return SamplingTestResponse(
855
+ success=True,
856
+ message="Sampling executed via server-side fallback",
857
+ response=response_text,
858
+ sampling_enabled=True,
859
+ used_fallback="Server-side OpenAI fallback was used",
860
+ )
861
+ except Exception as fallback_error:
862
+ logger.error(f"Server-side fallback failed: {fallback_error}")
863
+ return SamplingTestResponse(
864
+ success=False,
865
+ error=f"Both client and server sampling failed: {fallback_error}",
866
+ sampling_enabled=True,
867
+ note="Check OPENAI_API_KEY environment variable",
868
+ )
869
+
870
+ return SamplingTestResponse(
871
+ success=False,
872
+ error="Client does not support sampling",
873
+ sampling_enabled=False,
874
+ note="Enable server-side fallback with SAMPLING_ENABLED=true and OPENAI_API_KEY",
875
+ )
876
+
877
+ logger.error(f"Sampling test failed: {e}")
878
+ return SamplingTestResponse(
879
+ success=False,
880
+ error=str(e),
881
+ sampling_enabled=settings.enable_sampling,
882
+ )
883
+
884
+
885
+ async def register_oauth_tools_builtin(server: FastMCP):
886
+ """Register OAuth authentication tools.
887
+
888
+ :param server: FastMCP server instance.
889
+ """
890
+ oauth = OAuthTools(settings)
891
+
892
+ @server.tool(
893
+ name="start_oauth_flow",
894
+ description="Start the OAuth authorization flow for Amazon Ads API",
895
+ )
896
+ async def start_oauth_flow(ctx: Context):
897
+ """Start the OAuth authorization flow."""
898
+ return await oauth.start_oauth_flow(ctx)
899
+
900
+ @server.tool(
901
+ name="check_oauth_status",
902
+ description="Check the current OAuth authentication status",
903
+ )
904
+ async def check_oauth_status(ctx: Context):
905
+ """Check OAuth authentication status."""
906
+ return await oauth.check_oauth_status(ctx)
907
+
908
+ @server.tool(
909
+ name="refresh_oauth_token",
910
+ description="Manually refresh the OAuth access token",
911
+ )
912
+ async def refresh_oauth_token(ctx: Context):
913
+ """Refresh OAuth access token."""
914
+ return await oauth.refresh_access_token(ctx)
915
+
916
+ @server.tool(
917
+ name="clear_oauth_tokens",
918
+ description="Clear all stored OAuth tokens and state",
919
+ )
920
+ async def clear_oauth_tokens(ctx: Context):
921
+ """Clear OAuth tokens."""
922
+ return await oauth.clear_oauth_tokens(ctx)
923
+
924
+ logger.info("Registered OAuth authentication tools")
925
+
926
+
927
+ # Removed cache tools - not core operations
928
+
929
+
930
+ # Removed diagnostic tools - not core operations
931
+
932
+
933
+ async def register_all_builtin_tools(server: FastMCP):
934
+ """Register all built-in tools with the server.
935
+
936
+ :param server: FastMCP server instance.
937
+ """
938
+ # Register common tools that work for all auth types
939
+ await register_profile_tools(server)
940
+ await register_profile_listing_tools(server)
941
+ await register_region_tools(server)
942
+ # Routing tools removed - override functionality was redundant
943
+ await register_download_tools(server)
944
+ await register_reporting_tools(server)
945
+ await register_sampling_tools(server)
946
+ # Cache & diagnostic tools removed - not core operations
947
+
948
+ # Register auth-specific tools based on provider type
949
+ auth_mgr = get_auth_manager()
950
+ if auth_mgr and auth_mgr.provider:
951
+ # Check provider_type property (not auth_method attribute)
952
+ if hasattr(auth_mgr.provider, "provider_type"):
953
+ if auth_mgr.provider.provider_type == "direct":
954
+ # Direct OAuth authentication tools
955
+ await register_oauth_tools_builtin(server)
956
+ logger.info("Registered OAuth authentication tools")
957
+ elif auth_mgr.provider.provider_type == "openbridge":
958
+ # OpenBridge identity management tools
959
+ await register_identity_tools(server)
960
+ logger.info("Registered OpenBridge identity tools")
961
+
962
+ logger.info("Registered all built-in tools")