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.
- amazon_ads_mcp/__init__.py +11 -0
- amazon_ads_mcp/auth/__init__.py +33 -0
- amazon_ads_mcp/auth/base.py +211 -0
- amazon_ads_mcp/auth/hooks.py +172 -0
- amazon_ads_mcp/auth/manager.py +791 -0
- amazon_ads_mcp/auth/oauth_state_store.py +277 -0
- amazon_ads_mcp/auth/providers/__init__.py +14 -0
- amazon_ads_mcp/auth/providers/direct.py +393 -0
- amazon_ads_mcp/auth/providers/example_auth0.py.example +216 -0
- amazon_ads_mcp/auth/providers/openbridge.py +512 -0
- amazon_ads_mcp/auth/registry.py +146 -0
- amazon_ads_mcp/auth/secure_token_store.py +297 -0
- amazon_ads_mcp/auth/token_store.py +723 -0
- amazon_ads_mcp/config/__init__.py +5 -0
- amazon_ads_mcp/config/sampling.py +111 -0
- amazon_ads_mcp/config/settings.py +366 -0
- amazon_ads_mcp/exceptions.py +314 -0
- amazon_ads_mcp/middleware/__init__.py +11 -0
- amazon_ads_mcp/middleware/authentication.py +1474 -0
- amazon_ads_mcp/middleware/caching.py +177 -0
- amazon_ads_mcp/middleware/oauth.py +175 -0
- amazon_ads_mcp/middleware/sampling.py +112 -0
- amazon_ads_mcp/models/__init__.py +320 -0
- amazon_ads_mcp/models/amc_models.py +837 -0
- amazon_ads_mcp/models/api_responses.py +847 -0
- amazon_ads_mcp/models/base_models.py +215 -0
- amazon_ads_mcp/models/builtin_responses.py +496 -0
- amazon_ads_mcp/models/dsp_models.py +556 -0
- amazon_ads_mcp/models/stores_brands.py +610 -0
- amazon_ads_mcp/server/__init__.py +6 -0
- amazon_ads_mcp/server/__main__.py +6 -0
- amazon_ads_mcp/server/builtin_prompts.py +269 -0
- amazon_ads_mcp/server/builtin_tools.py +962 -0
- amazon_ads_mcp/server/file_routes.py +547 -0
- amazon_ads_mcp/server/html_templates.py +149 -0
- amazon_ads_mcp/server/mcp_server.py +327 -0
- amazon_ads_mcp/server/openapi_utils.py +158 -0
- amazon_ads_mcp/server/sampling_handler.py +251 -0
- amazon_ads_mcp/server/server_builder.py +751 -0
- amazon_ads_mcp/server/sidecar_loader.py +178 -0
- amazon_ads_mcp/server/transform_executor.py +827 -0
- amazon_ads_mcp/tools/__init__.py +22 -0
- amazon_ads_mcp/tools/cache_management.py +105 -0
- amazon_ads_mcp/tools/download_tools.py +267 -0
- amazon_ads_mcp/tools/identity.py +236 -0
- amazon_ads_mcp/tools/oauth.py +598 -0
- amazon_ads_mcp/tools/profile.py +150 -0
- amazon_ads_mcp/tools/profile_listing.py +285 -0
- amazon_ads_mcp/tools/region.py +320 -0
- amazon_ads_mcp/tools/region_identity.py +175 -0
- amazon_ads_mcp/utils/__init__.py +6 -0
- amazon_ads_mcp/utils/async_compat.py +215 -0
- amazon_ads_mcp/utils/errors.py +452 -0
- amazon_ads_mcp/utils/export_content_type_resolver.py +249 -0
- amazon_ads_mcp/utils/export_download_handler.py +579 -0
- amazon_ads_mcp/utils/header_resolver.py +81 -0
- amazon_ads_mcp/utils/http/__init__.py +56 -0
- amazon_ads_mcp/utils/http/circuit_breaker.py +127 -0
- amazon_ads_mcp/utils/http/client_manager.py +329 -0
- amazon_ads_mcp/utils/http/request.py +207 -0
- amazon_ads_mcp/utils/http/resilience.py +512 -0
- amazon_ads_mcp/utils/http/resilient_client.py +195 -0
- amazon_ads_mcp/utils/http/retry.py +76 -0
- amazon_ads_mcp/utils/http_client.py +873 -0
- amazon_ads_mcp/utils/media/__init__.py +21 -0
- amazon_ads_mcp/utils/media/negotiator.py +243 -0
- amazon_ads_mcp/utils/media/types.py +199 -0
- amazon_ads_mcp/utils/openapi/__init__.py +16 -0
- amazon_ads_mcp/utils/openapi/json.py +55 -0
- amazon_ads_mcp/utils/openapi/loader.py +263 -0
- amazon_ads_mcp/utils/openapi/refs.py +46 -0
- amazon_ads_mcp/utils/region_config.py +200 -0
- amazon_ads_mcp/utils/response_wrapper.py +171 -0
- amazon_ads_mcp/utils/sampling_helpers.py +156 -0
- amazon_ads_mcp/utils/sampling_wrapper.py +173 -0
- amazon_ads_mcp/utils/security.py +630 -0
- amazon_ads_mcp/utils/tool_naming.py +137 -0
- amazon_ads_mcp-0.2.7.dist-info/METADATA +664 -0
- amazon_ads_mcp-0.2.7.dist-info/RECORD +82 -0
- amazon_ads_mcp-0.2.7.dist-info/WHEEL +4 -0
- amazon_ads_mcp-0.2.7.dist-info/entry_points.txt +3 -0
- 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)
|