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,547 @@
1
+ """HTTP custom routes for file download operations.
2
+
3
+ These routes operate alongside the MCP endpoint to provide
4
+ actual file transfer capability that MCP cannot efficiently handle.
5
+
6
+ Architecture Pattern:
7
+ - Control Plane (MCP): list_downloads, get_download_url tools
8
+ - Data Plane (HTTP): GET /downloads/{path} for actual file bytes
9
+
10
+ Security:
11
+ - Profile-scoped directories (multi-tenant isolation)
12
+ - Path traversal prevention via resolve() + relative_to()
13
+ - Sensitive file blocking
14
+ - Configurable size limits and extension whitelist
15
+ - Bearer token authentication (optional)
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ from pathlib import Path
21
+ from typing import Optional
22
+ from urllib.parse import quote
23
+
24
+ from starlette.requests import Request
25
+ from starlette.responses import FileResponse, JSONResponse, Response
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Lazy import to avoid circular dependencies
30
+ settings = None
31
+
32
+
33
+ def _get_settings():
34
+ """Lazy load settings to avoid import cycles."""
35
+ global settings
36
+ if settings is None:
37
+ try:
38
+ from ..config.settings import settings as _settings
39
+
40
+ settings = _settings
41
+ except ImportError:
42
+ # Fallback to a mock settings object for testing
43
+ class MockSettings:
44
+ download_auth_token = None
45
+ download_max_file_size = 512 * 1024 * 1024 # 512MB
46
+ download_allowed_extensions = None
47
+
48
+ settings = MockSettings()
49
+ return settings
50
+
51
+
52
+ def get_auth_manager():
53
+ """Get the auth manager instance."""
54
+ try:
55
+ from ..auth.manager import get_auth_manager as _get_auth_manager
56
+
57
+ return _get_auth_manager()
58
+ except ImportError:
59
+ return None
60
+
61
+
62
+ def get_download_handler():
63
+ """Get the export download handler instance."""
64
+ try:
65
+ from ..utils.export_download_handler import get_download_handler as _get_handler
66
+
67
+ return _get_handler()
68
+ except ImportError:
69
+ return None
70
+
71
+
72
+ # =============================================================================
73
+ # Route Registration
74
+ # =============================================================================
75
+
76
+
77
+ def register_file_routes(server) -> None:
78
+ """Register HTTP file routes with the FastMCP server.
79
+
80
+ Args:
81
+ server: FastMCP server instance
82
+ """
83
+ if not hasattr(server, "custom_route"):
84
+ logger.warning(
85
+ "Server does not support custom routes (stdio transport?). "
86
+ "File download routes not registered."
87
+ )
88
+ return
89
+
90
+ @server.custom_route("/downloads/{file_path:path}", methods=["GET"])
91
+ async def download_file(request: Request) -> Response:
92
+ """Download a file by its path.
93
+
94
+ This endpoint serves files downloaded by export/report tools.
95
+ Files are validated to be within the allowed download directory
96
+ AND scoped to the current profile context.
97
+
98
+ Path Parameters:
99
+ file_path: The relative file path within downloads directory
100
+
101
+ Returns:
102
+ FileResponse with file content, or JSONResponse with error
103
+ """
104
+ file_path_str = request.path_params["file_path"]
105
+
106
+ # 1. Authentication (if enabled)
107
+ auth_error = await _verify_download_auth(request)
108
+ if auth_error is not None:
109
+ return auth_error
110
+
111
+ # 2. Get current profile for tenant isolation
112
+ profile_id = await _get_current_profile_id()
113
+ if not profile_id:
114
+ return _create_error_response(
115
+ error="No active profile",
116
+ error_code="NO_PROFILE",
117
+ status_code=401,
118
+ hint="Set active profile using set_active_profile before downloading",
119
+ )
120
+
121
+ # 3. Get profile-scoped base directory
122
+ handler = get_download_handler()
123
+ if not handler:
124
+ return _create_error_response(
125
+ error="Download handler not available",
126
+ error_code="HANDLER_ERROR",
127
+ status_code=500,
128
+ )
129
+
130
+ profile_dir = _get_profile_base_dir(handler, profile_id)
131
+
132
+ # 4. Resolve and validate file path
133
+ resolved_path = _resolve_file_path(file_path_str, profile_dir)
134
+ if resolved_path is None:
135
+ return _create_error_response(
136
+ error="File not found",
137
+ error_code="FILE_NOT_FOUND",
138
+ status_code=404,
139
+ file_path=file_path_str,
140
+ hint="Use list_downloads to see available files",
141
+ )
142
+
143
+ # 5. Security validation
144
+ security_error = _validate_file_access(resolved_path, profile_dir)
145
+ if security_error:
146
+ response = JSONResponse(security_error, status_code=403)
147
+ return _add_cors_headers(response)
148
+
149
+ # 6. Serve file
150
+ logger.info(f"Serving file: {resolved_path} for profile: {profile_id}")
151
+ response = FileResponse(
152
+ path=resolved_path,
153
+ filename=resolved_path.name,
154
+ media_type=_get_media_type(resolved_path),
155
+ )
156
+ return _add_cors_headers(response)
157
+
158
+ @server.custom_route("/downloads", methods=["GET"])
159
+ async def list_download_files(request: Request) -> JSONResponse:
160
+ """List available downloads with their download URLs.
161
+
162
+ Query Parameters:
163
+ type: Optional filter by export type (campaigns, reports, etc.)
164
+
165
+ Returns:
166
+ JSONResponse with list of files and their download URLs
167
+ """
168
+ auth_error = await _verify_download_auth(request)
169
+ if auth_error is not None:
170
+ return auth_error
171
+
172
+ # Get current profile for tenant isolation
173
+ profile_id = await _get_current_profile_id()
174
+ if not profile_id:
175
+ return _create_error_response(
176
+ error="No active profile",
177
+ error_code="NO_PROFILE",
178
+ status_code=401,
179
+ hint="Set active profile before listing files",
180
+ )
181
+
182
+ handler = get_download_handler()
183
+ if not handler:
184
+ return _create_error_response(
185
+ error="Download handler not available",
186
+ error_code="HANDLER_ERROR",
187
+ status_code=500,
188
+ )
189
+
190
+ profile_dir = _get_profile_base_dir(handler, profile_id)
191
+ export_type = request.query_params.get("type")
192
+
193
+ files = []
194
+ base_url = _get_base_url(request)
195
+
196
+ if not profile_dir.exists():
197
+ response = JSONResponse(
198
+ {"files": [], "count": 0, "profile_id": profile_id}
199
+ )
200
+ return _add_cors_headers(response)
201
+
202
+ for file_path in profile_dir.rglob("*"):
203
+ if file_path.is_file() and not file_path.name.endswith(".meta.json"):
204
+ relative_path = file_path.relative_to(profile_dir)
205
+
206
+ # Filter by type if specified
207
+ if export_type and export_type not in str(relative_path):
208
+ continue
209
+
210
+ # URL-encode path segments for special characters
211
+ encoded_path = "/".join(
212
+ quote(part, safe="") for part in relative_path.parts
213
+ )
214
+
215
+ file_info = {
216
+ "name": file_path.name,
217
+ "path": str(relative_path),
218
+ "size_bytes": file_path.stat().st_size,
219
+ "download_url": f"{base_url}/downloads/{encoded_path}",
220
+ }
221
+
222
+ # Include metadata if available
223
+ meta_path = Path(str(file_path) + ".meta.json")
224
+ if meta_path.exists():
225
+ try:
226
+ with open(meta_path) as f:
227
+ file_info["metadata"] = json.load(f)
228
+ except (json.JSONDecodeError, IOError):
229
+ pass
230
+
231
+ files.append(file_info)
232
+
233
+ response = JSONResponse(
234
+ {
235
+ "files": files,
236
+ "count": len(files),
237
+ "profile_id": profile_id,
238
+ }
239
+ )
240
+ return _add_cors_headers(response)
241
+
242
+ @server.custom_route("/downloads", methods=["OPTIONS"])
243
+ async def downloads_cors_preflight_root(request: Request) -> Response:
244
+ """Handle CORS preflight for /downloads."""
245
+ response = Response(status_code=204)
246
+ return _add_cors_headers(response)
247
+
248
+ @server.custom_route("/downloads/{file_path:path}", methods=["OPTIONS"])
249
+ async def downloads_cors_preflight_path(request: Request) -> Response:
250
+ """Handle CORS preflight for /downloads/{path}."""
251
+ response = Response(status_code=204)
252
+ return _add_cors_headers(response)
253
+
254
+ logger.info("Registered file download routes at /downloads")
255
+
256
+
257
+ # =============================================================================
258
+ # Helper Functions
259
+ # =============================================================================
260
+
261
+
262
+ async def _get_current_profile_id() -> Optional[str]:
263
+ """Get the current profile ID from auth context.
264
+
265
+ Returns:
266
+ Profile ID string or None if not available
267
+ """
268
+ auth_mgr = get_auth_manager()
269
+ if auth_mgr:
270
+ return auth_mgr.get_active_profile_id()
271
+ return None
272
+
273
+
274
+ def _get_profile_base_dir(handler, profile_id: str) -> Path:
275
+ """Get profile-scoped base directory.
276
+
277
+ Directory structure:
278
+ data/
279
+ ├── profiles/
280
+ │ ├── <profile_id_1>/
281
+ │ │ ├── exports/
282
+ │ │ │ └── campaigns/
283
+ │ │ └── reports/
284
+ │ │ └── async/
285
+ │ └── <profile_id_2>/
286
+ │ └── ...
287
+
288
+ Args:
289
+ handler: ExportDownloadHandler instance
290
+ profile_id: Current profile ID
291
+
292
+ Returns:
293
+ Path to profile-scoped directory
294
+
295
+ Raises:
296
+ ValueError: If profile_id is empty or None
297
+ """
298
+ if not profile_id:
299
+ raise ValueError("Profile ID required for file access")
300
+
301
+ profile_dir = handler.base_dir / "profiles" / profile_id
302
+ profile_dir.mkdir(parents=True, exist_ok=True)
303
+ return profile_dir
304
+
305
+
306
+ def _resolve_file_path(file_id: str, base_dir: Path) -> Optional[Path]:
307
+ """Resolve a file ID to an actual path.
308
+
309
+ Supports:
310
+ - Direct relative paths: "exports/campaigns/report.json"
311
+ - Export IDs: Look up in metadata files
312
+
313
+ Args:
314
+ file_id: File identifier or relative path
315
+ base_dir: Base directory to search within
316
+
317
+ Returns:
318
+ Resolved Path or None if not found
319
+ """
320
+ # Try as direct path first
321
+ direct_path = base_dir / file_id
322
+ if direct_path.exists() and direct_path.is_file():
323
+ return direct_path
324
+
325
+ # Look up by export_id in metadata files
326
+ for meta_file in base_dir.rglob("*.meta.json"):
327
+ try:
328
+ with open(meta_file) as f:
329
+ meta = json.load(f)
330
+ if meta.get("export_id") == file_id:
331
+ # Data file is the meta file path without .meta.json
332
+ data_file_name = meta_file.name.replace(".meta.json", "")
333
+ data_file = meta_file.parent / data_file_name
334
+ if data_file.exists():
335
+ return data_file
336
+ except (json.JSONDecodeError, IOError):
337
+ continue
338
+
339
+ return None
340
+
341
+
342
+ def _validate_file_access(file_path: Path, base_dir: Path) -> Optional[dict]:
343
+ """Validate that file access is allowed.
344
+
345
+ Security checks:
346
+ 1. Path traversal prevention (symlink-aware)
347
+ 2. File within allowed directory
348
+ 3. Not a sensitive file type
349
+ 4. File size within limits
350
+ 5. Extension whitelist (if configured)
351
+
352
+ Args:
353
+ file_path: Path to validate
354
+ base_dir: Allowed base directory
355
+
356
+ Returns:
357
+ None if access allowed, error dict otherwise
358
+ """
359
+ # 1. Path traversal prevention
360
+ try:
361
+ resolved = file_path.resolve()
362
+ base_resolved = base_dir.resolve()
363
+ resolved.relative_to(base_resolved)
364
+ except ValueError:
365
+ return {
366
+ "error": "Access denied: path traversal detected",
367
+ "error_code": "PATH_TRAVERSAL",
368
+ "allowed_directory": str(base_dir),
369
+ }
370
+
371
+ # 2. Block sensitive files
372
+ sensitive_patterns = [
373
+ ".env",
374
+ "credentials",
375
+ "secret",
376
+ ".key",
377
+ ".pem",
378
+ ".p12",
379
+ "token",
380
+ "password",
381
+ "private",
382
+ ]
383
+ if any(pattern in file_path.name.lower() for pattern in sensitive_patterns):
384
+ return {
385
+ "error": "Access denied: sensitive file type",
386
+ "error_code": "SENSITIVE_FILE",
387
+ }
388
+
389
+ # 3. File size enforcement
390
+ cfg = _get_settings()
391
+ file_size = file_path.stat().st_size
392
+ max_size = cfg.download_max_file_size
393
+ if file_size > max_size:
394
+ return {
395
+ "error": f"File too large: {file_size} bytes exceeds {max_size} bytes",
396
+ "error_code": "FILE_TOO_LARGE",
397
+ "file_size": file_size,
398
+ "max_size": max_size,
399
+ "hint": "Contact administrator to increase DOWNLOAD_MAX_FILE_SIZE",
400
+ }
401
+
402
+ # 4. Extension whitelist (if configured)
403
+ allowed_ext_str = cfg.download_allowed_extensions
404
+ if allowed_ext_str:
405
+ allowed_extensions = {
406
+ ext.strip().lower() for ext in allowed_ext_str.split(",") if ext.strip()
407
+ }
408
+ file_ext = file_path.suffix.lower()
409
+ if file_ext not in allowed_extensions:
410
+ return {
411
+ "error": f"File type not allowed: {file_ext}",
412
+ "error_code": "EXTENSION_NOT_ALLOWED",
413
+ "allowed_extensions": list(allowed_extensions),
414
+ }
415
+
416
+ return None
417
+
418
+
419
+ def _get_media_type(file_path: Path) -> str:
420
+ """Determine MIME type from file extension.
421
+
422
+ Args:
423
+ file_path: Path to get media type for
424
+
425
+ Returns:
426
+ MIME type string
427
+ """
428
+ extension_map = {
429
+ ".json": "application/json",
430
+ ".csv": "text/csv",
431
+ ".tsv": "text/tab-separated-values",
432
+ ".txt": "text/plain",
433
+ ".xml": "application/xml",
434
+ ".gz": "application/gzip",
435
+ ".zip": "application/zip",
436
+ ".jsonl": "application/x-ndjson",
437
+ }
438
+ return extension_map.get(file_path.suffix.lower(), "application/octet-stream")
439
+
440
+
441
+ async def _verify_download_auth(request: Request) -> Optional[JSONResponse]:
442
+ """Verify download authentication via Bearer token.
443
+
444
+ Args:
445
+ request: Starlette request object
446
+
447
+ Returns:
448
+ None if auth succeeds or is disabled
449
+ JSONResponse with 401 if auth fails
450
+ """
451
+ cfg = _get_settings()
452
+ # Settings now loads from AMAZON_ADS_DOWNLOAD_AUTH_TOKEN or DOWNLOAD_AUTH_TOKEN
453
+ auth_token = cfg.download_auth_token
454
+
455
+ if not auth_token:
456
+ # Auth disabled - allow access
457
+ return None
458
+
459
+ # Check Authorization header
460
+ auth_header = request.headers.get("Authorization", "")
461
+ if auth_header.startswith("Bearer "):
462
+ provided_token = auth_header.split(" ", 1)[1]
463
+ if provided_token == auth_token:
464
+ return None # Auth success
465
+
466
+ response = JSONResponse(
467
+ {
468
+ "error": "Unauthorized",
469
+ "error_code": "UNAUTHORIZED",
470
+ "hint": "Provide Authorization: Bearer <token> header",
471
+ },
472
+ status_code=401,
473
+ )
474
+ return _add_cors_headers(response)
475
+
476
+
477
+ def _get_base_url(request: Request) -> str:
478
+ """Get the correct base URL, respecting proxy headers.
479
+
480
+ Handles X-Forwarded-Proto and X-Forwarded-Host from reverse proxies.
481
+
482
+ Args:
483
+ request: Starlette request object
484
+
485
+ Returns:
486
+ Base URL string without trailing slash
487
+ """
488
+ forwarded_proto = request.headers.get("X-Forwarded-Proto")
489
+ forwarded_host = request.headers.get("X-Forwarded-Host")
490
+
491
+ if forwarded_proto and forwarded_host:
492
+ return f"{forwarded_proto}://{forwarded_host}"
493
+
494
+ # Fallback to request.base_url
495
+ base_url = str(request.base_url).rstrip("/")
496
+
497
+ # Fix common proxy misconfiguration
498
+ if forwarded_proto == "https" and base_url.startswith("http://"):
499
+ base_url = base_url.replace("http://", "https://", 1)
500
+
501
+ return base_url
502
+
503
+
504
+ def _create_error_response(
505
+ error: str,
506
+ error_code: str,
507
+ status_code: int = 400,
508
+ **extra_fields,
509
+ ) -> JSONResponse:
510
+ """Create standardized error response with CORS headers.
511
+
512
+ Args:
513
+ error: Human-readable error message
514
+ error_code: Machine-readable error code
515
+ status_code: HTTP status code
516
+ **extra_fields: Additional fields to include
517
+
518
+ Returns:
519
+ JSONResponse with error details and CORS headers
520
+ """
521
+ body = {
522
+ "error": error,
523
+ "error_code": error_code,
524
+ }
525
+ if "hint" in extra_fields:
526
+ body["hint"] = extra_fields.pop("hint")
527
+ body.update(extra_fields)
528
+ response = JSONResponse(body, status_code=status_code)
529
+ return _add_cors_headers(response)
530
+
531
+
532
+ def _add_cors_headers(response: Response) -> Response:
533
+ """Add CORS headers for browser access.
534
+
535
+ Args:
536
+ response: Starlette response object
537
+
538
+ Returns:
539
+ Response with CORS headers added
540
+ """
541
+ response.headers["Access-Control-Allow-Origin"] = "*"
542
+ response.headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"
543
+ response.headers["Access-Control-Allow-Headers"] = "Authorization"
544
+ response.headers["Access-Control-Expose-Headers"] = (
545
+ "Content-Disposition, Content-Length"
546
+ )
547
+ return response
@@ -0,0 +1,149 @@
1
+ """HTML templates for OAuth callback responses.
2
+
3
+ This module contains HTML templates used in OAuth callback responses
4
+ to avoid inline HTML in the server code and prevent exposing sensitive
5
+ error details.
6
+ """
7
+
8
+
9
+ def get_error_html(title: str = "OAuth Error", message: str = None) -> str:
10
+ """Generate error HTML response.
11
+
12
+ Args:
13
+ title: Error page title
14
+ message: User-friendly error message (no sensitive details)
15
+
16
+ Returns:
17
+ HTML string for error response
18
+ """
19
+ if not message:
20
+ message = "An error occurred during authentication. Please try again."
21
+
22
+ return f"""
23
+ <!DOCTYPE html>
24
+ <html>
25
+ <head>
26
+ <title>{title}</title>
27
+ <style>
28
+ body {{ font-family: Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }}
29
+ .error {{ background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 15px; border-radius: 4px; }}
30
+ h1 {{ color: #dc3545; }}
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <h1>❌ {title}</h1>
35
+ <div class="error">
36
+ <p>{message}</p>
37
+ <p>Please restart the OAuth flow or contact support if the issue persists.</p>
38
+ </div>
39
+ </body>
40
+ </html>
41
+ """
42
+
43
+
44
+ def get_success_html(title: str = "Authorization Successful") -> str:
45
+ """Generate success HTML response.
46
+
47
+ Args:
48
+ title: Success page title
49
+
50
+ Returns:
51
+ HTML string for success response
52
+ """
53
+ return f"""
54
+ <!DOCTYPE html>
55
+ <html>
56
+ <head>
57
+ <title>OAuth Success</title>
58
+ <style>
59
+ body {{ font-family: Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }}
60
+ .success {{ background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; }}
61
+ h1 {{ color: #28a745; }}
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <h1>✅ {title}!</h1>
66
+ <div class="success">
67
+ <p>Your Amazon Ads API OAuth authentication is complete.</p>
68
+ <p>You can close this window and return to your MCP client.</p>
69
+ </div>
70
+ <script>setTimeout(() => window.close(), 5000);</script>
71
+ </body>
72
+ </html>
73
+ """
74
+
75
+
76
+ def get_validation_error_html() -> str:
77
+ """Generate security validation error HTML.
78
+
79
+ Returns:
80
+ HTML string for validation error
81
+ """
82
+ return get_error_html(
83
+ title="Security Validation Failed",
84
+ message="The state parameter could not be validated.",
85
+ )
86
+
87
+
88
+ def get_missing_params_html() -> str:
89
+ """Generate missing parameters error HTML.
90
+
91
+ Returns:
92
+ HTML string for missing parameters error
93
+ """
94
+ return get_error_html(
95
+ title="Invalid Request",
96
+ message="Missing required parameters. Please restart the OAuth flow.",
97
+ )
98
+
99
+
100
+ def get_token_storage_error_html() -> str:
101
+ """Generate token storage error HTML.
102
+
103
+ Returns:
104
+ HTML string for token storage error
105
+ """
106
+ return """
107
+ <!DOCTYPE html>
108
+ <html>
109
+ <head>
110
+ <title>OAuth Error</title>
111
+ <style>
112
+ body { font-family: Arial, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }
113
+ .error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 15px; border-radius: 4px; }
114
+ h1 { color: #dc3545; }
115
+ </style>
116
+ </head>
117
+ <body>
118
+ <h1>⚠️ Token Storage Failed</h1>
119
+ <div class="error">
120
+ <p>Authentication succeeded but tokens could not be stored securely.</p>
121
+ <p>Please check your token storage configuration and try again.</p>
122
+ </div>
123
+ </body>
124
+ </html>
125
+ """
126
+
127
+
128
+ def get_token_exchange_error_html() -> str:
129
+ """Generate token exchange error HTML.
130
+
131
+ Returns:
132
+ HTML string for token exchange error
133
+ """
134
+ return get_error_html(
135
+ title="Authorization Failed",
136
+ message="Failed to exchange authorization code for tokens. Please check your client configuration.",
137
+ )
138
+
139
+
140
+ def get_server_error_html() -> str:
141
+ """Generate generic server error HTML.
142
+
143
+ Returns:
144
+ HTML string for server error
145
+ """
146
+ return get_error_html(
147
+ title="Server Error",
148
+ message="An unexpected error occurred during authentication.",
149
+ )