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,327 @@
1
+ #!/usr/bin/env python3
2
+ """Amazon Ads MCP Server - Modular Implementation.
3
+
4
+ This is a refactored version of the MCP server that uses modular components
5
+ for better maintainability and testability.
6
+
7
+ Implements the FastMCP server lifespan pattern for clean startup/shutdown.
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import atexit
13
+ import logging
14
+ import os
15
+ import signal
16
+ import sys
17
+ import types
18
+ from collections.abc import AsyncIterator
19
+ from contextlib import asynccontextmanager
20
+ from typing import Any, Optional
21
+
22
+ # Note: FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER removed in v0.1.18
23
+ # The new OpenAPI parser is now standard in FastMCP 2.14.0+
24
+ # See: https://gofastmcp.com/v2/development/upgrade-guide
25
+
26
+ from ..utils.http import http_client_manager
27
+ from ..utils.security import setup_secure_logging
28
+ from .server_builder import ServerBuilder
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @asynccontextmanager
34
+ async def server_lifespan(server: Any) -> AsyncIterator[None]:
35
+ """Server lifespan context manager for clean startup and shutdown.
36
+
37
+ This lifespan function handles all server initialization and cleanup
38
+ in a single, testable async context manager. It's passed to the FastMCP
39
+ constructor to ensure proper resource management.
40
+
41
+ FastMCP 2.14+ passes the server instance to lifespan functions, which
42
+ enables access to server state during startup/shutdown.
43
+
44
+ Startup:
45
+ - Validates authentication configuration
46
+ - Initializes HTTP client connections
47
+ - Logs server state
48
+
49
+ Shutdown:
50
+ - Closes all HTTP client connections
51
+ - Shuts down the authentication manager
52
+ - Performs graceful cleanup
53
+
54
+ Usage:
55
+ server = FastMCP("Server", lifespan=server_lifespan)
56
+
57
+ :param server: The FastMCP server instance (provided by FastMCP)
58
+ :yield: Control to the running server
59
+ :rtype: AsyncIterator[None]
60
+ """
61
+ _ = server # Server instance available if needed for future enhancements
62
+ logger.info("Server lifespan: Starting up...")
63
+
64
+ # Startup phase
65
+ try:
66
+ # Log initial state
67
+ from ..auth.manager import get_auth_manager
68
+
69
+ auth_mgr = get_auth_manager()
70
+ if auth_mgr and auth_mgr.provider:
71
+ provider_type = getattr(auth_mgr.provider, "provider_type", "unknown")
72
+ logger.info(f"Auth provider initialized: {provider_type}")
73
+ else:
74
+ logger.warning("No auth provider configured")
75
+
76
+ logger.info("Server lifespan: Startup complete")
77
+ except Exception as e:
78
+ logger.error(f"Server lifespan: Startup error: {e}")
79
+ raise
80
+
81
+ try:
82
+ # Yield control to the running server
83
+ yield
84
+ finally:
85
+ # Shutdown phase - coordinate with fallback cleanup handlers
86
+ global _cleanup_done
87
+
88
+ if _cleanup_done:
89
+ logger.debug("Server lifespan: Cleanup already done by fallback handler")
90
+ return
91
+
92
+ logger.info("Server lifespan: Shutting down...")
93
+
94
+ # Close HTTP clients
95
+ try:
96
+ await http_client_manager.close_all()
97
+ logger.info("HTTP clients closed")
98
+ except Exception as e:
99
+ logger.error(f"Error closing HTTP clients: {e}")
100
+
101
+ # Close auth manager
102
+ try:
103
+ from ..auth.manager import get_auth_manager
104
+
105
+ am = get_auth_manager()
106
+ if am:
107
+ await am.close()
108
+ logger.info("Auth manager closed")
109
+ except Exception as e:
110
+ logger.error(f"Error closing auth manager: {e}")
111
+
112
+ # Mark cleanup done to prevent fallback handlers from running again
113
+ _cleanup_done = True
114
+ logger.info("Server lifespan: Shutdown complete")
115
+
116
+
117
+ async def create_amazon_ads_server() -> Any:
118
+ """Create and configure the Amazon Ads MCP server using modular components.
119
+
120
+ This function creates a new Amazon Ads MCP server instance using the
121
+ modular ServerBuilder. The server is fully configured with builtin tools,
122
+ authentication middleware, and lifespan management.
123
+
124
+ The lifespan pattern (FastMCP 2.14+) handles:
125
+ - Clean startup with auth validation
126
+ - Graceful shutdown with resource cleanup
127
+ - HTTP client connection management
128
+ - Auth manager lifecycle
129
+
130
+ :return: Configured FastMCP server instance
131
+ :raises Exception: If server initialization fails
132
+
133
+ Examples
134
+ --------
135
+ .. code-block:: python
136
+
137
+ server = await create_amazon_ads_server()
138
+ await server.run()
139
+ """
140
+ # Pass the lifespan to the builder for clean startup/shutdown
141
+ builder = ServerBuilder(lifespan=server_lifespan)
142
+ server = await builder.build()
143
+
144
+ # Built-in tools are registered in ServerBuilder._setup_builtin_tools()
145
+ # FastMCP handles prompts automatically
146
+
147
+ logger.info("MCP server setup complete (lifespan pattern enabled)")
148
+ return server
149
+
150
+
151
+ _cleanup_task = None
152
+ _cleanup_done = False
153
+
154
+
155
+ async def cleanup_resources_async() -> None:
156
+ """Perform async cleanup of server resources.
157
+
158
+ This function performs asynchronous cleanup of server resources including
159
+ HTTP client connections and authentication manager shutdown. It ensures
160
+ that cleanup is only performed once and handles errors gracefully.
161
+
162
+ :raises Exception: If cleanup operations fail
163
+ """
164
+ global _cleanup_done
165
+ if _cleanup_done:
166
+ return
167
+
168
+ logger.info("Shutting down server...")
169
+ try:
170
+ await http_client_manager.close_all()
171
+ logger.info("HTTP clients closed")
172
+ except Exception as e:
173
+ logger.error("Error closing http clients: %s", e)
174
+
175
+ try:
176
+ from ..auth.manager import get_auth_manager
177
+
178
+ am = get_auth_manager()
179
+ if am:
180
+ await am.close()
181
+ logger.info("Auth manager closed")
182
+ except Exception as e:
183
+ logger.error("Error closing auth manager: %s", e)
184
+
185
+ _cleanup_done = True
186
+
187
+
188
+ def cleanup_sync() -> None:
189
+ """Synchronously clean up server resources.
190
+
191
+ This function performs cleanup operations for the server in a safe manner
192
+ that avoids creating new event loops in signal handlers.
193
+
194
+ The cleanup includes:
195
+ - Closing all HTTP client connections
196
+ - Shutting down the authentication manager
197
+ - Handling any cleanup errors gracefully
198
+ """
199
+ global _cleanup_task, _cleanup_done
200
+
201
+ if _cleanup_done:
202
+ return
203
+
204
+ # Try to schedule cleanup in existing event loop
205
+ try:
206
+ loop = asyncio.get_running_loop()
207
+ if not loop.is_closed() and not _cleanup_task:
208
+ _cleanup_task = loop.create_task(cleanup_resources_async())
209
+ logger.debug("Cleanup scheduled in running event loop")
210
+ return
211
+ except RuntimeError:
212
+ # No running loop - that's OK for signal handlers
213
+ pass
214
+
215
+ # For atexit (not signal handlers), we can try more thorough cleanup
216
+ frame: Optional[types.FrameType] = sys._getframe()
217
+ # Check if we're in a signal handler by inspecting the stack
218
+ in_signal = False
219
+ while frame:
220
+ if frame.f_code.co_name in (
221
+ "<module>",
222
+ "<lambda>",
223
+ ) and "signal" in str(frame.f_code.co_filename):
224
+ in_signal = True
225
+ break
226
+ frame = frame.f_back
227
+
228
+ if not in_signal and not _cleanup_done:
229
+ # Safe to create new event loop when not in signal handler
230
+ try:
231
+ loop = asyncio.new_event_loop()
232
+ asyncio.set_event_loop(loop)
233
+ loop.run_until_complete(cleanup_resources_async())
234
+ loop.close()
235
+ logger.info("Cleanup complete via new event loop")
236
+ except Exception as e:
237
+ logger.debug(f"Could not perform sync cleanup: {e}")
238
+
239
+
240
+ def main() -> None:
241
+ """Run the Amazon Ads MCP server.
242
+
243
+ This is the main entry point for the Amazon Ads MCP server. It parses
244
+ command line arguments, initializes logging, creates the server, and
245
+ starts it with the specified transport.
246
+
247
+ The function supports multiple transport modes:
248
+ - stdio: Standard input/output communication
249
+ - http: HTTP-based communication
250
+ - streamable-http: Server-sent events HTTP communication
251
+
252
+ :raises KeyboardInterrupt: If the server is stopped by user interrupt
253
+ :raises Exception: If server initialization or startup fails
254
+
255
+ Examples
256
+ --------
257
+ .. code-block:: bash
258
+
259
+ # Run with HTTP transport
260
+ python -m amazon_ads_mcp.server.mcp_server --transport http --port 9080
261
+
262
+ # Run with stdio transport
263
+ python -m amazon_ads_mcp.server.mcp_server --transport stdio
264
+ """
265
+ # Load environment variables from .env file if it exists
266
+ try:
267
+ from dotenv import load_dotenv
268
+
269
+ load_dotenv()
270
+ except ImportError:
271
+ pass
272
+
273
+ setup_secure_logging(level=os.environ.get("LOG_LEVEL", "INFO"))
274
+ logger.debug("Environment variables loaded")
275
+
276
+ parser = argparse.ArgumentParser(description="Amazon Ads MCP Server (Modular)")
277
+ parser.add_argument(
278
+ "--transport",
279
+ choices=["stdio", "http", "streamable-http"],
280
+ default="stdio",
281
+ )
282
+ parser.add_argument("--host", default="127.0.0.1")
283
+ parser.add_argument("--port", type=int, default=9080)
284
+ args = parser.parse_args()
285
+
286
+ # Set the port in environment for OAuth redirect URI
287
+ if args.transport in ("http", "streamable-http"):
288
+ os.environ["PORT"] = str(args.port)
289
+
290
+ # Register cleanup handlers
291
+ atexit.register(cleanup_sync)
292
+ signal.signal(signal.SIGTERM, lambda *_: cleanup_sync())
293
+ signal.signal(signal.SIGINT, lambda *_: cleanup_sync())
294
+
295
+ logger.info("Creating Amazon Ads MCP server...")
296
+ mcp = asyncio.run(create_amazon_ads_server())
297
+
298
+ # Small delay to ensure server is fully initialized
299
+ import time
300
+
301
+ time.sleep(0.5)
302
+ logger.info("Server initialization complete")
303
+
304
+ try:
305
+ if args.transport in ("http", "streamable-http"):
306
+ transport = (
307
+ "streamable-http" if args.transport == "streamable-http" else "http"
308
+ )
309
+ logger.info("Starting %s server on %s:%d", transport, args.host, args.port)
310
+ # Use streamable-http transport which handles SSE properly
311
+ mcp.run(
312
+ transport=transport,
313
+ host=args.host,
314
+ port=args.port,
315
+ # Using default path to avoid redirect issues
316
+ )
317
+ else:
318
+ logger.info("Running in stdio mode")
319
+ mcp.run()
320
+ except KeyboardInterrupt:
321
+ logger.info("Server stopped by user")
322
+ finally:
323
+ cleanup_sync()
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()
@@ -0,0 +1,158 @@
1
+ """OpenAPI utilities for the MCP server.
2
+
3
+ This module provides utilities for processing OpenAPI specifications,
4
+ including slimming large descriptions and managing spec resources.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+
10
+ def truncate_text(text: Optional[str], max_len: int) -> Optional[str]:
11
+ """Truncate text to a maximum length with ellipsis.
12
+
13
+ :param text: Text to truncate
14
+ :type text: Optional[str]
15
+ :param max_len: Maximum length
16
+ :type max_len: int
17
+ :return: Truncated text or original if shorter
18
+ :rtype: Optional[str]
19
+ """
20
+ if not isinstance(text, str):
21
+ return text
22
+ if len(text) <= max_len:
23
+ return text
24
+ tail = "…"
25
+ return text[: max(0, max_len - len(tail))] + tail
26
+
27
+
28
+ def slim_openapi_for_tools(spec: Dict[str, Any], max_desc: int = 200) -> None:
29
+ """Reduce large descriptions in OpenAPI operations and parameters.
30
+
31
+ This helps keep tool metadata small when clients ingest tool definitions.
32
+ Modifies the spec in place.
33
+
34
+ :param spec: OpenAPI specification to slim
35
+ :type spec: Dict[str, Any]
36
+ :param max_desc: Maximum description length
37
+ :type max_desc: int
38
+ """
39
+ try:
40
+ auth_header_names = {
41
+ "Authorization",
42
+ "Amazon-Advertising-API-ClientId",
43
+ "Amazon-Advertising-API-Scope",
44
+ }
45
+ auth_parameter_keys: set[str] = set()
46
+
47
+ def resolve_local_ref(ref: str) -> Any:
48
+ if not ref.startswith("#/"):
49
+ return None
50
+ current: Any = spec
51
+ for part in ref[2:].split("/"):
52
+ if not isinstance(current, dict) or part not in current:
53
+ return None
54
+ current = current[part]
55
+ return current
56
+
57
+ def is_auth_parameter_ref(ref: str) -> bool:
58
+ if not ref.startswith("#/components/parameters/"):
59
+ return False
60
+ key = ref.split("/")[-1]
61
+ return key in auth_parameter_keys
62
+
63
+ def is_auth_header_param(param: Dict[str, Any]) -> bool:
64
+ if (
65
+ param.get("in") == "header"
66
+ and param.get("name") in auth_header_names
67
+ ):
68
+ return True
69
+
70
+ ref = param.get("$ref")
71
+ if isinstance(ref, str) and ref.startswith("#/"):
72
+ if is_auth_parameter_ref(ref):
73
+ return True
74
+ resolved = resolve_local_ref(ref)
75
+ if isinstance(resolved, dict):
76
+ return (
77
+ resolved.get("in") == "header"
78
+ and resolved.get("name") in auth_header_names
79
+ )
80
+ return False
81
+
82
+ spec.pop("externalDocs", None)
83
+
84
+ # Fix server URLs that have descriptions in them
85
+ if "servers" in spec and isinstance(spec["servers"], list):
86
+ fixed_servers = []
87
+ for server in spec["servers"]:
88
+ if isinstance(server, dict) and "url" in server:
89
+ url = server["url"]
90
+ # Extract just the URL part if it contains description
91
+ if " (" in url:
92
+ url = url.split(" (")[0].strip()
93
+ fixed_servers.append({"url": url})
94
+ if fixed_servers:
95
+ # Use the first server as default (North America)
96
+ spec["servers"] = [fixed_servers[0]]
97
+
98
+ # Remove auth header params that are supplied by auth middleware/client
99
+ components = spec.get("components")
100
+ if isinstance(components, dict):
101
+ params = components.get("parameters")
102
+ if isinstance(params, dict):
103
+ for key, param in params.items():
104
+ if isinstance(param, dict) and is_auth_header_param(param):
105
+ auth_parameter_keys.add(key)
106
+
107
+ for p, methods in (spec.get("paths") or {}).items():
108
+ if not isinstance(methods, dict):
109
+ continue
110
+
111
+ # Path-item parameters
112
+ path_params = methods.get("parameters") or []
113
+ if isinstance(path_params, list):
114
+ filtered_path_params = []
115
+ for prm in path_params:
116
+ if isinstance(prm, dict) and "description" in prm:
117
+ prm["description"] = truncate_text(prm.get("description"), max_desc)
118
+ if isinstance(prm, dict) and is_auth_header_param(prm):
119
+ continue
120
+ filtered_path_params.append(prm)
121
+ methods["parameters"] = filtered_path_params
122
+
123
+ for m, op in list(methods.items()):
124
+ if not isinstance(op, dict):
125
+ continue
126
+ # Trim top-level description
127
+ if "description" in op:
128
+ op["description"] = truncate_text(op.get("description"), max_desc)
129
+ # Prefer summary if description missing or too long
130
+ if not op.get("description") and op.get("summary"):
131
+ op["description"] = truncate_text(op.get("summary"), max_desc)
132
+ op.pop("externalDocs", None)
133
+ # Parameters
134
+ params = op.get("parameters") or []
135
+ if isinstance(params, list):
136
+ filtered_params = []
137
+ for prm in params:
138
+ if isinstance(prm, dict) and "description" in prm:
139
+ prm["description"] = truncate_text(
140
+ prm.get("description"), max_desc
141
+ )
142
+ if isinstance(prm, dict) and is_auth_header_param(prm):
143
+ continue
144
+ filtered_params.append(prm)
145
+ op["parameters"] = filtered_params
146
+ # Request body description
147
+ req = op.get("requestBody")
148
+ if isinstance(req, dict) and "description" in req:
149
+ req["description"] = truncate_text(req.get("description"), max_desc)
150
+
151
+ if auth_parameter_keys and isinstance(components, dict):
152
+ params = components.get("parameters")
153
+ if isinstance(params, dict):
154
+ for key in auth_parameter_keys:
155
+ params.pop(key, None)
156
+ except Exception:
157
+ # Do not fail mounting if slimming fails
158
+ pass