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,751 @@
1
+ """Server builder module for creating configured MCP servers.
2
+
3
+ This module handles the complex server initialization process,
4
+ including middleware setup, client configuration, and resource mounting.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+
12
+ from fastmcp import FastMCP
13
+ from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
14
+
15
+ from ..auth.manager import get_auth_manager
16
+ from ..config.settings import settings
17
+ from ..middleware.authentication import (
18
+ create_auth_middleware,
19
+ create_openbridge_config,
20
+ )
21
+
22
+ try:
23
+ from ..middleware.oauth import create_oauth_middleware
24
+ except ImportError:
25
+ create_oauth_middleware = None
26
+ from ..utils.header_resolver import HeaderNameResolver
27
+ from ..utils.http_client import AuthenticatedClient
28
+ from ..utils.media import MediaTypeRegistry
29
+ from ..utils.region_config import RegionConfig
30
+ from .openapi_utils import slim_openapi_for_tools
31
+ from .sidecar_loader import _json_load as json_load
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class ServerBuilder:
37
+ """Builder class for creating configured MCP servers.
38
+
39
+ This class encapsulates the complex server setup process,
40
+ making it easier to test and maintain.
41
+ """
42
+
43
+ def __init__(self, lifespan=None):
44
+ """Initialize the server builder.
45
+
46
+ :param lifespan: Optional async context manager for server lifespan.
47
+ If provided, handles startup/shutdown logic.
48
+ :type lifespan: Optional[Callable[[], AsyncContextManager]]
49
+ """
50
+ # Parser flag will be set at runtime in main(), not at import time
51
+ self.server: Optional[FastMCP] = None
52
+ self.lifespan = lifespan # Server lifespan context manager
53
+ self.auth_manager = get_auth_manager()
54
+ self.media_registry = MediaTypeRegistry()
55
+ self.header_resolver = HeaderNameResolver()
56
+ self.mounted_servers: Dict[str, FastMCP] = {}
57
+
58
+ async def build(self) -> FastMCP:
59
+ """Build and configure the MCP server.
60
+
61
+ :return: Configured FastMCP server instance
62
+ :rtype: FastMCP
63
+ """
64
+ # Ensure default identity is loaded if configured
65
+ await self._setup_default_identity()
66
+
67
+ # Create the main server
68
+ self.server = await self._create_main_server()
69
+
70
+ # Setup middleware
71
+ await self._setup_middleware()
72
+
73
+ # Setup HTTP client
74
+ self.client = await self._setup_http_client()
75
+
76
+ # Mount resource servers
77
+ await self._mount_resource_servers()
78
+
79
+ # Setup built-in tools
80
+ await self._setup_builtin_tools()
81
+
82
+ # Setup built-in prompts
83
+ await self._setup_builtin_prompts()
84
+
85
+ # Setup OAuth callback route for HTTP transport
86
+ await self._setup_oauth_callback()
87
+
88
+ # Setup file download routes for HTTP transport
89
+ await self._setup_file_routes()
90
+
91
+ return self.server
92
+
93
+ async def _setup_default_identity(self):
94
+ """Setup default identity if configured."""
95
+ if hasattr(self.auth_manager, "_default_identity_id"):
96
+ await self.auth_manager.set_active_identity(
97
+ self.auth_manager._default_identity_id
98
+ )
99
+
100
+ async def _create_main_server(self) -> FastMCP:
101
+ """Create the main FastMCP server instance.
102
+
103
+ :return: Main server instance
104
+ :rtype: FastMCP
105
+ """
106
+ # Create server with appropriate configuration
107
+ # Include lifespan if provided for clean startup/shutdown handling
108
+ server = FastMCP(
109
+ "Amazon Ads MCP Server",
110
+ version="1.0.0",
111
+ lifespan=self.lifespan, # FastMCP 2.14+ lifespan pattern
112
+ )
113
+
114
+ # Setup server-side sampling handler if enabled
115
+ if settings.enable_sampling:
116
+ try:
117
+ from .sampling_handler import create_sampling_handler
118
+
119
+ # Create the sampling handler
120
+ sampling_handler = create_sampling_handler()
121
+
122
+ if sampling_handler:
123
+ # Use the sampling wrapper instead of private attribute
124
+ from ..utils.sampling_wrapper import (
125
+ configure_sampling_handler,
126
+ )
127
+
128
+ configure_sampling_handler(sampling_handler)
129
+ logger.info("Server-side sampling handler configured via wrapper")
130
+ else:
131
+ logger.info(
132
+ "Server-side sampling not configured (missing config or disabled)"
133
+ )
134
+
135
+ except Exception as e:
136
+ logger.error(f"Failed to setup sampling handler: {e}")
137
+ else:
138
+ logger.info("Sampling is disabled in settings")
139
+
140
+ return server
141
+
142
+ async def _setup_middleware(self):
143
+ """Setup server middleware."""
144
+ middleware_list = []
145
+
146
+ # Error callback for logging
147
+ def error_callback(error: Exception) -> None:
148
+ logger.error(f"Tool execution error: {type(error).__name__}: {error}")
149
+
150
+ # Add ErrorHandlingMiddleware FIRST to catch all errors from other middleware/tools
151
+ # Production config: no tracebacks exposed, consistent error transformation
152
+ error_middleware = ErrorHandlingMiddleware(
153
+ include_traceback=False, # Don't expose internal details
154
+ transform_errors=True, # Provide consistent error responses
155
+ error_callback=error_callback, # Log errors for debugging
156
+ )
157
+ middleware_list.append(error_middleware)
158
+ logger.info("Added ErrorHandlingMiddleware for consistent error handling")
159
+
160
+ # Add response caching middleware (security-aware whitelist)
161
+ if settings.enable_response_caching:
162
+ from ..middleware.caching import create_caching_middleware
163
+
164
+ caching_middleware = create_caching_middleware()
165
+ middleware_list.append(caching_middleware)
166
+ logger.info("Added ResponseCachingMiddleware with security-aware whitelist")
167
+
168
+ # Add sampling middleware if configured
169
+ from ..utils.sampling_wrapper import get_sampling_wrapper
170
+
171
+ wrapper = get_sampling_wrapper()
172
+ if wrapper.has_handler():
173
+ from ..middleware.sampling import create_sampling_middleware
174
+
175
+ sampling_middleware = create_sampling_middleware()
176
+ if sampling_middleware:
177
+ middleware_list.append(sampling_middleware)
178
+ logger.info("Added server-side sampling middleware")
179
+
180
+ # Add OpenBridge middleware if using OpenBridge auth
181
+ provider_type = getattr(self.auth_manager.provider, "provider_type", None)
182
+ if provider_type == "openbridge":
183
+ ob_config = create_openbridge_config()
184
+ auth_middlewares = create_auth_middleware(
185
+ ob_config, auth_manager=self.auth_manager
186
+ )
187
+ # create_auth_middleware returns a list, so extend instead of append
188
+ middleware_list.extend(auth_middlewares)
189
+ logger.info(
190
+ f"Added {len(auth_middlewares)} OpenBridge authentication middleware components"
191
+ )
192
+
193
+ # Add OAuth middleware if credentials are available
194
+ if create_oauth_middleware and all(
195
+ [
196
+ settings.oauth_client_id,
197
+ settings.oauth_client_secret,
198
+ settings.oauth_redirect_uri,
199
+ ]
200
+ ):
201
+ oauth_middleware = create_oauth_middleware()
202
+ middleware_list.append(oauth_middleware)
203
+ logger.info("Added OAuth middleware for web authentication")
204
+
205
+ # Apply middleware to server
206
+ for middleware in middleware_list:
207
+ self.server.middleware.append(middleware)
208
+
209
+ async def _setup_http_client(self) -> AuthenticatedClient:
210
+ """Setup the authenticated HTTP client.
211
+
212
+ :return: Configured HTTP client
213
+ :rtype: AuthenticatedClient
214
+ """
215
+ # Auth-aware base URL selection
216
+
217
+ # Determine base URL based on auth provider type
218
+ if self.auth_manager and hasattr(self.auth_manager.provider, "provider_type"):
219
+ provider_type = self.auth_manager.provider.provider_type
220
+
221
+ if provider_type == "openbridge":
222
+ # For OpenBridge: Default to NA at startup
223
+ # The real region will be determined from the identity at request time
224
+ region = "na"
225
+ logger.info(
226
+ "OpenBridge: Using default NA base URL at startup (per-request routing will override based on identity)"
227
+ )
228
+ else:
229
+ # For Direct auth: use configured region from settings
230
+ region = settings.amazon_ads_region
231
+ logger.info(
232
+ f"Direct auth: Using configured region '{region}' from settings"
233
+ )
234
+ else:
235
+ # Fallback to settings region if no auth manager
236
+ region = settings.amazon_ads_region
237
+ logger.warning(
238
+ f"No auth manager available, using region '{region}' from settings"
239
+ )
240
+
241
+ base_url = RegionConfig.get_api_endpoint(region)
242
+
243
+ import httpx
244
+
245
+ return AuthenticatedClient(
246
+ auth_manager=self.auth_manager,
247
+ media_registry=self.media_registry,
248
+ header_resolver=self.header_resolver,
249
+ base_url=base_url,
250
+ timeout=httpx.Timeout(
251
+ # Allow longer timeouts for Amazon Ads API
252
+ connect=10.0, # Connection timeout
253
+ read=60.0, # Read timeout for response
254
+ write=10.0, # Write timeout for request
255
+ pool=10.0, # Pool timeout
256
+ ),
257
+ )
258
+
259
+ async def _mount_resource_servers(self):
260
+ """Mount resource servers for API isolation."""
261
+
262
+ # Always prefer dist/ directory if it exists (minified specs)
263
+ dist_resources = Path("dist/openapi/resources")
264
+ source_resources = Path("openapi/resources")
265
+ packaged_resources = Path(__file__).resolve().parent.parent / "resources"
266
+
267
+ if dist_resources.exists():
268
+ resources_dir = dist_resources
269
+ logger.info(f"Using optimized resources from {resources_dir}")
270
+ elif source_resources.exists():
271
+ resources_dir = source_resources
272
+ logger.info(f"Using source resources from {resources_dir}")
273
+ elif packaged_resources.exists():
274
+ resources_dir = packaged_resources
275
+ logger.info(f"Using packaged resources from {resources_dir}")
276
+ else:
277
+ logger.warning("No resources directory found")
278
+ return
279
+
280
+ # Load namespace mapping and package allowlist (if any)
281
+ namespace_mapping = await self._load_namespace_mapping(resources_dir)
282
+ package_allowlist = await self._load_package_allowlist(resources_dir)
283
+
284
+ # Process each resource file
285
+ skip_files = {"packages.json", "manifest.json"}
286
+ for spec_path in sorted(resources_dir.glob("*.json")):
287
+ # Skip metadata files and sidecars
288
+ if spec_path.name in skip_files:
289
+ logger.debug(f"Skipping metadata file: {spec_path.name}")
290
+ continue
291
+
292
+ # Skip sidecar files (check suffix, not substring)
293
+ if spec_path.stem.endswith((".media", ".manifest", ".transform")):
294
+ logger.debug(f"Skipping sidecar file: {spec_path.name}")
295
+ continue
296
+
297
+ # Skip if not in package allowlist (when set)
298
+ ns = spec_path.stem
299
+ if package_allowlist:
300
+ if ns not in package_allowlist:
301
+ logger.debug(
302
+ "Skipping %s - not in AMAZON_AD_API_PACKAGES allowlist",
303
+ ns,
304
+ )
305
+ continue
306
+
307
+ await self._mount_single_resource(spec_path, namespace_mapping)
308
+
309
+ async def _load_namespace_mapping(self, resources_dir: Path) -> Dict[str, str]:
310
+ """Load namespace to prefix mapping from packages.json.
311
+
312
+ :return: Namespace to prefix mapping
313
+ :rtype: Dict[str, str]
314
+ """
315
+ # Try multiple locations for packages.json: alongside resources or project root
316
+ candidates = [
317
+ resources_dir.parent / "packages.json",
318
+ resources_dir / "packages.json",
319
+ Path("openapi/packages.json"),
320
+ ]
321
+ packages_path = next((p for p in candidates if p.exists()), None)
322
+ if not packages_path:
323
+ return {}
324
+
325
+ try:
326
+ data = json_load(packages_path)
327
+ mapping: Dict[str, str] = {}
328
+
329
+ # Preferred: explicit prefixes map
330
+ prefixes = data.get("prefixes") if isinstance(data, dict) else None
331
+ if isinstance(prefixes, dict):
332
+ for ns, pref in prefixes.items():
333
+ if isinstance(ns, str) and isinstance(pref, str):
334
+ mapping[ns] = pref
335
+
336
+ # Back-compat: some generators might emit a flat map with {ns: {prefix: "..."}}
337
+ if not mapping:
338
+ for ns, info in data.items() if isinstance(data, dict) else []:
339
+ if (
340
+ isinstance(info, dict)
341
+ and "prefix" in info
342
+ and isinstance(info["prefix"], str)
343
+ ):
344
+ mapping[ns] = info["prefix"]
345
+
346
+ return mapping
347
+ except Exception as e:
348
+ logger.error(f"Failed to load packages.json: {e}")
349
+ return {}
350
+
351
+ async def _load_package_allowlist(
352
+ self, resources_dir: Path
353
+ ) -> Dict[str, None] | set:
354
+ """Load allowed packages from environment and resolve to resource namespaces.
355
+
356
+ Supports aliases defined in packages.json. Returns a set of allowed
357
+ resource namespaces (matching the .json stem names). Empty set means
358
+ no restriction.
359
+ """
360
+ # Load packages.json to resolve aliases -> namespaces and read defaults
361
+ candidates = [
362
+ resources_dir.parent / "packages.json",
363
+ resources_dir / "packages.json",
364
+ Path("openapi/packages.json"),
365
+ ]
366
+ packages_path = next((p for p in candidates if p.exists()), None)
367
+
368
+ alias_map: Dict[str, str] = {}
369
+ default_tokens: list[str] = []
370
+ if packages_path:
371
+ try:
372
+ data = json_load(packages_path)
373
+ # `aliases` is a map: alias_slug -> NamespaceName
374
+ aliases = data.get("aliases") if isinstance(data, dict) else None
375
+ if isinstance(aliases, dict):
376
+ for alias, ns in aliases.items():
377
+ if isinstance(alias, str) and isinstance(ns, str):
378
+ alias_map[alias.lower()] = ns
379
+ # Optional defaults list (aliases or namespace stems)
380
+ defaults_val = data.get("defaults") if isinstance(data, dict) else None
381
+ if isinstance(defaults_val, list):
382
+ default_tokens = [str(v).strip().lower() for v in defaults_val if str(v).strip()]
383
+ except Exception as e:
384
+ logger.debug("Failed to read aliases/defaults from %s: %s", packages_path, e)
385
+
386
+ # Determine requested tokens from env or defaults
387
+ raw = os.getenv("AMAZON_AD_API_PACKAGES") or os.getenv("AD_API_PACKAGES")
388
+ requested: set[str]
389
+ if raw:
390
+ raw = raw.strip()
391
+ # Strip surrounding quotes if present (Windows compatibility)
392
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
393
+ logger.debug("Stripping quotes from AMAZON_AD_API_PACKAGES value")
394
+ raw = raw[1:-1]
395
+ requested = {part.strip().lower() for part in raw.split(",") if part.strip()}
396
+ logger.debug("Parsed AMAZON_AD_API_PACKAGES: %s", requested)
397
+ else:
398
+ # Use packages.json defaults when available; otherwise fall back to a safe minimal set
399
+ fallback_defaults = ["profiles", "accounts-ads-accounts"]
400
+ requested = set(default_tokens or fallback_defaults)
401
+ logger.info("Using default package allowlist: %s", ", ".join(sorted(requested)))
402
+
403
+ if not requested:
404
+ # If we somehow ended up empty, do not restrict
405
+ return set()
406
+
407
+ # Build allowlist: map requested tokens using alias_map when possible.
408
+ allow: set[str] = set()
409
+ for token in requested:
410
+ # Exact alias -> namespace
411
+ if token in alias_map:
412
+ allow.add(alias_map[token])
413
+ continue
414
+ # Accept tokens that are already namespace (file stem) names
415
+ # Attempt case-insensitive match against available specs
416
+ for spec_path in resources_dir.glob("*.json"):
417
+ stem = spec_path.stem
418
+ if stem.endswith((".media", ".manifest", ".transform")):
419
+ continue
420
+ if spec_path.name in {"packages.json", "manifest.json"}:
421
+ continue
422
+ if stem.lower() == token:
423
+ allow.add(stem)
424
+
425
+ if allow:
426
+ logger.info(
427
+ "Package allowlist active (%d): %s",
428
+ len(allow),
429
+ ", ".join(sorted(allow)),
430
+ )
431
+ else:
432
+ logger.warning(
433
+ "No packages resolved from requested tokens; loading nothing.")
434
+ return allow
435
+
436
+ async def _mount_single_resource(
437
+ self, spec_path: Path, namespace_mapping: Dict[str, str]
438
+ ):
439
+ """Mount a single resource server.
440
+
441
+ :param spec_path: Path to the OpenAPI spec
442
+ :type spec_path: Path
443
+ :param namespace_mapping: Namespace to prefix mapping
444
+ :type namespace_mapping: Dict[str, str]
445
+ """
446
+ try:
447
+ # Load the spec
448
+ spec = json_load(spec_path)
449
+
450
+ # Validate it's an OpenAPI spec
451
+ if not isinstance(spec, dict) or "openapi" not in spec:
452
+ logger.warning(f"Skipping {spec_path.name} - not an OpenAPI spec")
453
+ return
454
+
455
+ # Determine namespace and prefix first (before using it)
456
+ namespace = spec_path.stem
457
+
458
+ # Populate registries from spec (media types and headers)
459
+ self.media_registry.add_from_spec(spec)
460
+ self.header_resolver.add_from_spec(spec)
461
+
462
+ # Load and apply media type sidecar if it exists
463
+ media_path = spec_path.with_suffix(".media.json")
464
+ if media_path.exists():
465
+ try:
466
+ media_spec = json_load(media_path)
467
+ self.media_registry.add_from_spec(media_spec)
468
+ logger.debug(f"Loaded media types from {media_path.name}")
469
+ except Exception as e:
470
+ logger.warning(
471
+ f"Failed to load media sidecar {media_path.name}: {e}"
472
+ )
473
+
474
+ # Slim the spec
475
+ slim_openapi_for_tools(spec)
476
+
477
+ # Auth-aware server URL configuration
478
+ if self.auth_manager and hasattr(
479
+ self.auth_manager.provider, "provider_type"
480
+ ):
481
+ provider_type = self.auth_manager.provider.provider_type
482
+
483
+ if provider_type == "openbridge":
484
+ # For OpenBridge: Don't hardcode a regional server
485
+ # Runtime routing will override based on identity
486
+ spec["servers"] = [
487
+ {
488
+ "url": RegionConfig.get_api_endpoint("na"),
489
+ "description": "Runtime routing will override based on identity",
490
+ }
491
+ ]
492
+ logger.debug(
493
+ f"OpenBridge: Spec {namespace} servers will be overridden at runtime"
494
+ )
495
+ else:
496
+ # For Direct auth: use configured region
497
+ region = settings.amazon_ads_region
498
+ # Use centralized region config
499
+ base_url = RegionConfig.get_api_endpoint(region)
500
+ spec["servers"] = [{"url": base_url}]
501
+ logger.debug(f"Direct auth: Spec {namespace} using {region} server")
502
+ else:
503
+ # Fallback to settings region
504
+ region = settings.amazon_ads_region
505
+ # Use centralized region config
506
+ base_url = RegionConfig.get_api_endpoint(region)
507
+ spec["servers"] = [{"url": base_url}]
508
+
509
+ # Get prefix from mapping
510
+ prefix = namespace_mapping.get(namespace, namespace)
511
+
512
+ # Create sub-server from OpenAPI spec
513
+ # Default timeout of 60s to prevent hanging on slow API responses
514
+ sub_server = FastMCP.from_openapi(
515
+ openapi_spec=spec,
516
+ client=self.client,
517
+ name=prefix,
518
+ timeout=60.0, # 60 second timeout for all OpenAPI-generated tools
519
+ )
520
+
521
+ # Mount the sub-server with prefix
522
+ self.server.mount(server=sub_server, prefix=prefix)
523
+ self.mounted_servers[namespace] = sub_server
524
+
525
+ # Apply sidecars (transforms) to the mounted sub-server
526
+ from .sidecar_loader import apply_sidecars
527
+
528
+ await apply_sidecars(sub_server, spec_path)
529
+
530
+ logger.info(f"Mounted {namespace} with prefix '{prefix}'")
531
+
532
+ except Exception as e:
533
+ logger.error(f"Failed to mount {spec_path}: {e}")
534
+
535
+ async def _setup_builtin_tools(self):
536
+ """Setup built-in tools for the server."""
537
+ from ..server.builtin_tools import register_all_builtin_tools
538
+
539
+ await register_all_builtin_tools(self.server)
540
+
541
+ async def _setup_builtin_prompts(self):
542
+ """Setup built-in prompts for the server."""
543
+ from ..server.builtin_prompts import register_all_builtin_prompts
544
+
545
+ await register_all_builtin_prompts(self.server)
546
+
547
+ async def _setup_oauth_callback(self):
548
+ """Setup OAuth callback route for HTTP transport."""
549
+ # Only register OAuth callback route for HTTP transport
550
+ # Check if server has custom_route method (HTTP transport)
551
+ if hasattr(self.server, "custom_route"):
552
+ import httpx
553
+ from starlette.requests import Request
554
+ from starlette.responses import HTMLResponse
555
+
556
+ @self.server.custom_route("/auth/callback", methods=["GET"])
557
+ async def oauth_callback(request: Request):
558
+ """Handle OAuth callback from Amazon with secure state validation."""
559
+ import os
560
+
561
+ from ..auth.oauth_state_store import get_oauth_state_store
562
+
563
+ code = request.query_params.get("code")
564
+ state = request.query_params.get("state")
565
+ scope = request.query_params.get("scope")
566
+ error = request.query_params.get("error")
567
+ error_description = request.query_params.get("error_description")
568
+
569
+ # Handle OAuth errors from Amazon
570
+ if error:
571
+ logger.error(f"OAuth error: {error} - {error_description}")
572
+ # Don't expose internal error details in HTML
573
+ from .html_templates import get_error_html
574
+
575
+ html = get_error_html(
576
+ title="Authorization Failed",
577
+ message="The authorization request could not be completed.",
578
+ )
579
+ return HTMLResponse(html, status_code=400)
580
+
581
+ # Validate required parameters
582
+ if not code or not state:
583
+ logger.error("Missing code or state in OAuth callback")
584
+ from .html_templates import get_missing_params_html
585
+
586
+ html = get_missing_params_html()
587
+ return HTMLResponse(html, status_code=400)
588
+
589
+ # Extract user agent and IP for validation
590
+ user_agent = request.headers.get("user-agent")
591
+ ip_address = request.client.host if request.client else None
592
+
593
+ logger.info(
594
+ f"OAuth callback received: code=[REDACTED], state=[REDACTED], scope={scope}"
595
+ )
596
+
597
+ try:
598
+ # Validate state with secure store
599
+ state_store = get_oauth_state_store()
600
+ is_valid, error_message = state_store.validate_state(
601
+ state=state,
602
+ user_agent=user_agent,
603
+ ip_address=ip_address,
604
+ )
605
+
606
+ if not is_valid:
607
+ logger.warning(f"Invalid OAuth state: {error_message}")
608
+ from .html_templates import get_validation_error_html
609
+
610
+ html = get_validation_error_html()
611
+ return HTMLResponse(html, status_code=403)
612
+
613
+ # State is valid, proceed with token exchange
614
+ token_url = RegionConfig.get_oauth_endpoint(
615
+ settings.amazon_ads_region
616
+ )
617
+
618
+ # Use explicit timeout for OAuth token exchange
619
+ timeout = httpx.Timeout(
620
+ connect=10.0, read=30.0, write=10.0, pool=10.0
621
+ )
622
+ async with httpx.AsyncClient(timeout=timeout) as client:
623
+ response = await client.post(
624
+ token_url,
625
+ data={
626
+ "grant_type": "authorization_code",
627
+ "code": code,
628
+ # Use PORT env var or request port or default
629
+ "redirect_uri": f"http://localhost:{os.getenv('PORT') or request.url.port or 9080}/auth/callback",
630
+ "client_id": settings.ad_api_client_id,
631
+ "client_secret": settings.ad_api_client_secret,
632
+ },
633
+ )
634
+
635
+ if response.status_code == 200:
636
+ tokens = response.json()
637
+
638
+ # Store tokens securely
639
+ try:
640
+ from datetime import datetime, timedelta, timezone
641
+
642
+ from ..auth.secure_token_store import (
643
+ get_secure_token_store,
644
+ )
645
+
646
+ secure_store = get_secure_token_store()
647
+
648
+ if "refresh_token" in tokens:
649
+ secure_store.store_token(
650
+ token_id="oauth_refresh_token",
651
+ token_value=tokens["refresh_token"],
652
+ token_type="refresh",
653
+ expires_at=datetime.now(timezone.utc)
654
+ + timedelta(days=365),
655
+ metadata={"scope": tokens.get("scope")},
656
+ )
657
+
658
+ if "access_token" in tokens:
659
+ secure_store.store_token(
660
+ token_id="oauth_access_token",
661
+ token_value=tokens["access_token"],
662
+ token_type="access",
663
+ expires_at=datetime.now(timezone.utc)
664
+ + timedelta(seconds=tokens.get("expires_in", 3600)),
665
+ metadata={
666
+ "token_type": tokens.get("token_type", "Bearer")
667
+ },
668
+ )
669
+
670
+ logger.info("Stored tokens in secure token store")
671
+ except Exception as e:
672
+ logger.error(f"Failed to store tokens securely: {e}")
673
+ # Don't expose internal error details
674
+ from .html_templates import (
675
+ get_token_storage_error_html,
676
+ )
677
+
678
+ html = get_token_storage_error_html()
679
+ return HTMLResponse(html, status_code=500)
680
+
681
+ # Store in auth manager if available
682
+ if self.auth_manager:
683
+ from datetime import datetime, timedelta, timezone
684
+
685
+ from ..auth.token_store import TokenKind
686
+
687
+ # Store refresh token
688
+ if "refresh_token" in tokens:
689
+ await self.auth_manager.set_token(
690
+ provider_type="direct",
691
+ identity_id="direct-auth",
692
+ token_kind=TokenKind.REFRESH,
693
+ token=tokens["refresh_token"],
694
+ expires_at=datetime.now(timezone.utc)
695
+ + timedelta(days=365),
696
+ metadata={},
697
+ )
698
+
699
+ # Store access token
700
+ expires_at = datetime.now(timezone.utc) + timedelta(
701
+ seconds=tokens.get("expires_in", 3600)
702
+ )
703
+ await self.auth_manager.set_token(
704
+ provider_type="direct",
705
+ identity_id="direct-auth",
706
+ token_kind=TokenKind.ACCESS,
707
+ token=tokens["access_token"],
708
+ expires_at=expires_at,
709
+ metadata={"token_type": "Bearer"},
710
+ )
711
+
712
+ logger.info("Stored OAuth tokens in auth manager")
713
+
714
+ # Success response
715
+ from .html_templates import get_success_html
716
+
717
+ html = get_success_html()
718
+ return HTMLResponse(html)
719
+ else:
720
+ # Error response
721
+ error_msg = response.text
722
+ logger.error(
723
+ f"Token exchange failed: {response.status_code} - {error_msg}"
724
+ )
725
+
726
+ from .html_templates import (
727
+ get_token_exchange_error_html,
728
+ )
729
+
730
+ html = get_token_exchange_error_html()
731
+ return HTMLResponse(html, status_code=400)
732
+
733
+ except Exception as e:
734
+ logger.error(f"OAuth callback error: {e}")
735
+ # Don't expose internal exception details
736
+ from .html_templates import get_server_error_html
737
+
738
+ html = get_server_error_html()
739
+ return HTMLResponse(html, status_code=500)
740
+
741
+ logger.info("Registered OAuth callback route at /auth/callback")
742
+
743
+ async def _setup_file_routes(self):
744
+ """Setup HTTP file download routes.
745
+
746
+ Registers custom routes for downloading files via HTTP.
747
+ Only effective with HTTP transport (not stdio).
748
+ """
749
+ from .file_routes import register_file_routes
750
+
751
+ register_file_routes(self.server)