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