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,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
|
+
)
|