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,297 @@
1
+ """Secure token storage with encryption."""
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ from cryptography.fernet import Fernet
12
+ from cryptography.hazmat.backends import default_backend
13
+ from cryptography.hazmat.primitives import hashes
14
+
15
+ from ..exceptions import TokenError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SecureTokenStore:
21
+ """
22
+ Secure storage for sensitive tokens with encryption.
23
+
24
+ This store provides:
25
+ - Encryption at rest using Fernet (AES-128)
26
+ - Key derivation from environment variable or auto-generated
27
+ - File-based storage with atomic writes
28
+ - Memory caching for performance
29
+ - Automatic cleanup of expired tokens
30
+
31
+ For production use, consider using a proper secrets manager
32
+ like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ storage_path: Optional[Path] = None,
38
+ encryption_key: Optional[str] = None,
39
+ ):
40
+ """
41
+ Initialize secure token store.
42
+
43
+ Args:
44
+ storage_path: Path for encrypted token storage
45
+ encryption_key: Base64-encoded encryption key or password
46
+ """
47
+ self.storage_path = storage_path or self._get_default_path()
48
+ self._fernet = self._initialize_encryption(encryption_key)
49
+ self._memory_cache: Dict[str, Dict[str, Any]] = {}
50
+ self._load_tokens()
51
+
52
+ def _get_default_path(self) -> Path:
53
+ """Get default storage path."""
54
+ # Use XDG_DATA_HOME on Linux/Mac, APPDATA on Windows
55
+ if os.name == "nt": # Windows
56
+ base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
57
+ else: # Unix-like
58
+ base = Path(
59
+ os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
60
+ )
61
+
62
+ path = base / "amazon-ads-mcp" / "tokens.enc"
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ return path
65
+
66
+ def _initialize_encryption(self, key_input: Optional[str]) -> Fernet:
67
+ """
68
+ Initialize encryption with key derivation.
69
+
70
+ Args:
71
+ key_input: Base64 key or password to derive key from
72
+
73
+ Returns:
74
+ Fernet encryption instance
75
+ """
76
+ if not key_input:
77
+ # Try universal environment variable
78
+ key_input = os.getenv("AMAZON_ADS_ENCRYPTION_KEY")
79
+
80
+ if not key_input:
81
+ # Generate and store a key if none exists
82
+ key_file = self.storage_path.parent / ".key"
83
+ if key_file.exists():
84
+ try:
85
+ with open(key_file, "rb") as f:
86
+ key = f.read()
87
+ except Exception as e:
88
+ logger.warning(f"Could not read encryption key: {e}")
89
+ key = Fernet.generate_key()
90
+ else:
91
+ key = Fernet.generate_key()
92
+ try:
93
+ # Save key with restricted permissions
94
+ with open(key_file, "wb") as f:
95
+ f.write(key)
96
+ os.chmod(key_file, 0o600) # Owner read/write only
97
+ except Exception as e:
98
+ logger.warning(f"Could not save encryption key: {e}")
99
+ else:
100
+ # Derive key from password if not base64
101
+ try:
102
+ # Try to decode as base64 first
103
+ key = base64.urlsafe_b64decode(key_input)
104
+ if len(key) != 32:
105
+ raise ValueError("Invalid key length")
106
+ except Exception:
107
+ # Not base64, derive key from password
108
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import (
109
+ PBKDF2HMAC,
110
+ )
111
+
112
+ kdf = PBKDF2HMAC(
113
+ algorithm=hashes.SHA256(),
114
+ length=32,
115
+ salt=b"amazon-ads-mcp-salt", # Fixed salt for deterministic key
116
+ iterations=100000,
117
+ backend=default_backend(),
118
+ )
119
+ key = base64.urlsafe_b64encode(kdf.derive(key_input.encode()))
120
+
121
+ return Fernet(key)
122
+
123
+ def store_token(
124
+ self,
125
+ token_id: str,
126
+ token_value: str,
127
+ token_type: str = "refresh",
128
+ expires_at: Optional[datetime] = None,
129
+ metadata: Optional[Dict[str, Any]] = None,
130
+ ):
131
+ """
132
+ Store an encrypted token.
133
+
134
+ Args:
135
+ token_id: Unique identifier for the token
136
+ token_value: The sensitive token value
137
+ token_type: Type of token (refresh, access, etc.)
138
+ expires_at: Optional expiration time
139
+ metadata: Optional metadata to store with token
140
+ """
141
+ # Prepare token entry
142
+ entry = {
143
+ "id": token_id,
144
+ "type": token_type,
145
+ "value": token_value,
146
+ "stored_at": datetime.now(timezone.utc).isoformat(),
147
+ "expires_at": expires_at.isoformat() if expires_at else None,
148
+ "metadata": metadata or {},
149
+ }
150
+
151
+ # Encrypt the token value
152
+ encrypted_value = self._fernet.encrypt(token_value.encode()).decode()
153
+ entry["value"] = encrypted_value
154
+
155
+ # Store in memory cache (unencrypted for performance)
156
+ self._memory_cache[token_id] = {
157
+ **entry,
158
+ "value": token_value, # Keep unencrypted in memory
159
+ }
160
+
161
+ # Persist to disk
162
+ self._save_tokens()
163
+
164
+ logger.debug(f"Stored {token_type} token: {token_id}")
165
+
166
+ def get_token(self, token_id: str) -> Optional[Dict[str, Any]]:
167
+ """
168
+ Retrieve a token by ID.
169
+
170
+ Args:
171
+ token_id: The token identifier
172
+
173
+ Returns:
174
+ Token entry with decrypted value, or None if not found/expired
175
+ """
176
+ # Check memory cache first
177
+ if token_id in self._memory_cache:
178
+ entry = self._memory_cache[token_id]
179
+ if self._is_expired(entry):
180
+ del self._memory_cache[token_id]
181
+ self._save_tokens()
182
+ return None
183
+ return entry
184
+
185
+ # Not in cache, shouldn't happen but handle gracefully
186
+ self._load_tokens()
187
+ return self._memory_cache.get(token_id)
188
+
189
+ def delete_token(self, token_id: str):
190
+ """Delete a token."""
191
+ if token_id in self._memory_cache:
192
+ del self._memory_cache[token_id]
193
+ self._save_tokens()
194
+ logger.debug(f"Deleted token: {token_id}")
195
+
196
+ def clear_all(self):
197
+ """Clear all stored tokens."""
198
+ self._memory_cache.clear()
199
+ if self.storage_path.exists():
200
+ self.storage_path.unlink()
201
+ logger.info("Cleared all tokens")
202
+
203
+ def _is_expired(self, entry: Dict[str, Any]) -> bool:
204
+ """Check if a token entry is expired."""
205
+ if not entry.get("expires_at"):
206
+ return False
207
+
208
+ try:
209
+ expires_at = datetime.fromisoformat(entry["expires_at"])
210
+ return datetime.now(timezone.utc) > expires_at
211
+ except Exception:
212
+ return False
213
+
214
+ def _save_tokens(self):
215
+ """Save tokens to encrypted storage."""
216
+ try:
217
+ # Prepare data for storage (with encrypted values)
218
+ storage_data = {}
219
+ for token_id, entry in self._memory_cache.items():
220
+ # Skip expired tokens
221
+ if self._is_expired(entry):
222
+ continue
223
+
224
+ # Encrypt the value for storage
225
+ encrypted_entry = entry.copy()
226
+ encrypted_entry["value"] = self._fernet.encrypt(
227
+ entry["value"].encode()
228
+ ).decode()
229
+ storage_data[token_id] = encrypted_entry
230
+
231
+ # Serialize and encrypt entire file
232
+ json_data = json.dumps(storage_data, indent=2)
233
+ encrypted_data = self._fernet.encrypt(json_data.encode())
234
+
235
+ # Atomic write
236
+ tmp_path = self.storage_path.with_suffix(".tmp")
237
+ with open(tmp_path, "wb") as f:
238
+ f.write(encrypted_data)
239
+
240
+ # Set restrictive permissions
241
+ os.chmod(tmp_path, 0o600)
242
+
243
+ # Atomic replace
244
+ tmp_path.replace(self.storage_path)
245
+
246
+ except Exception as e:
247
+ logger.error(f"Failed to save tokens: {e}")
248
+ raise TokenError(f"Failed to save tokens: {e}")
249
+
250
+ def _load_tokens(self):
251
+ """Load tokens from encrypted storage."""
252
+ if not self.storage_path.exists():
253
+ return
254
+
255
+ try:
256
+ # Read and decrypt file
257
+ with open(self.storage_path, "rb") as f:
258
+ encrypted_data = f.read()
259
+
260
+ decrypted_data = self._fernet.decrypt(encrypted_data)
261
+ storage_data = json.loads(decrypted_data)
262
+
263
+ # Load into memory cache with decrypted values
264
+ self._memory_cache.clear()
265
+ for token_id, entry in storage_data.items():
266
+ # Skip expired tokens
267
+ if self._is_expired(entry):
268
+ continue
269
+
270
+ # Decrypt the token value
271
+ encrypted_value = entry["value"]
272
+ decrypted_value = self._fernet.decrypt(
273
+ encrypted_value.encode()
274
+ ).decode()
275
+ entry["value"] = decrypted_value
276
+
277
+ self._memory_cache[token_id] = entry
278
+
279
+ logger.debug(f"Loaded {len(self._memory_cache)} tokens")
280
+
281
+ except Exception as e:
282
+ logger.warning(f"Could not load tokens: {e}")
283
+ # Don't fail, just start fresh
284
+ self._memory_cache.clear()
285
+
286
+
287
+ # Global instance
288
+ _secure_token_store: Optional[SecureTokenStore] = None
289
+
290
+
291
+ def get_secure_token_store() -> SecureTokenStore:
292
+ """Get or create the global secure token store."""
293
+ global _secure_token_store
294
+ if _secure_token_store is None:
295
+ encryption_key = os.getenv("AMAZON_ADS_ENCRYPTION_KEY")
296
+ _secure_token_store = SecureTokenStore(encryption_key=encryption_key)
297
+ return _secure_token_store