meta-ads-mcp 0.4.3__tar.gz → 0.4.4__tar.gz

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 (51) hide show
  1. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/adsets.py +24 -69
  4. meta_ads_mcp-0.4.4/meta_ads_mcp/core/callback_server.py +257 -0
  5. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/pyproject.toml +1 -1
  6. meta_ads_mcp-0.4.3/meta_ads_mcp/core/callback_server.py +0 -1021
  7. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.github/workflows/publish.yml +0 -0
  8. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.github/workflows/test.yml +0 -0
  9. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.gitignore +0 -0
  10. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/CUSTOM_META_APP.md +0 -0
  11. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/Dockerfile +0 -0
  12. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/LICENSE +0 -0
  13. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/LOCAL_INSTALLATION.md +0 -0
  14. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/META_API_NOTES.md +0 -0
  15. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/README.md +0 -0
  16. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/RELEASE.md +0 -0
  17. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/STREAMABLE_HTTP_SETUP.md +0 -0
  18. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/examples/README.md +0 -0
  19. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/examples/example_http_client.py +0 -0
  20. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/future_improvements.md +0 -0
  21. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/images/meta-ads-example.png +0 -0
  22. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_auth.sh +0 -0
  23. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/__main__.py +0 -0
  24. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/__init__.py +0 -0
  25. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/accounts.py +0 -0
  26. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/ads.py +0 -0
  27. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/ads_library.py +0 -0
  28. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/api.py +0 -0
  29. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/auth.py +0 -0
  30. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/authentication.py +0 -0
  31. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/budget_schedules.py +0 -0
  32. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/campaigns.py +0 -0
  33. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/duplication.py +0 -0
  34. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  35. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/insights.py +0 -0
  36. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  37. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/reports.py +0 -0
  38. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/resources.py +0 -0
  39. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/server.py +0 -0
  40. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/utils.py +0 -0
  41. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/requirements.txt +0 -0
  42. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/setup.py +0 -0
  43. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/smithery.yaml +0 -0
  44. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/README.md +0 -0
  45. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/README_REGRESSION_TESTS.md +0 -0
  46. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/__init__.py +0 -0
  47. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/conftest.py +0 -0
  48. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_duplication.py +0 -0
  49. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_duplication_regression.py +0 -0
  50. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_http_transport.py +0 -0
  51. {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_openai.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.4.3"
10
+ __version__ = "0.4.4"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -5,9 +5,6 @@ from typing import Optional, Dict, Any, List
5
5
  from .api import meta_api_tool, make_api_request
6
6
  from .accounts import get_ad_accounts
7
7
  from .server import mcp_server
8
- import asyncio
9
- from .callback_server import start_callback_server, shutdown_callback_server, update_confirmation
10
- import urllib.parse
11
8
 
12
9
 
13
10
  @mcp_server.tool()
@@ -211,94 +208,52 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
211
208
  bid_strategy: Bid strategy (e.g., 'LOWEST_COST_WITH_BID_CAP')
212
209
  bid_amount: Bid amount in account currency (in cents for USD)
213
210
  status: Update ad set status (ACTIVE, PAUSED, etc.)
214
- targeting: Targeting specifications including targeting_automation
215
- (e.g. {"targeting_automation":{"advantage_audience":1}})
211
+ targeting: Complete targeting specifications (will replace existing targeting)
212
+ (e.g. {"targeting_automation":{"advantage_audience":1}, "geo_locations": {"countries": ["US"]}})
216
213
  optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'CONVERSIONS', 'APP_INSTALLS', etc.)
217
214
  access_token: Meta API access token (optional - will use cached token if not provided)
218
215
  """
219
216
  if not adset_id:
220
217
  return json.dumps({"error": "No ad set ID provided"}, indent=2)
221
218
 
222
- changes = {}
219
+ params = {}
223
220
 
224
221
  if frequency_control_specs is not None:
225
- changes['frequency_control_specs'] = frequency_control_specs
222
+ params['frequency_control_specs'] = frequency_control_specs
226
223
 
227
224
  if bid_strategy is not None:
228
- changes['bid_strategy'] = bid_strategy
225
+ params['bid_strategy'] = bid_strategy
229
226
 
230
227
  if bid_amount is not None:
231
- changes['bid_amount'] = bid_amount
228
+ params['bid_amount'] = str(bid_amount)
232
229
 
233
230
  if status is not None:
234
- changes['status'] = status
231
+ params['status'] = status
235
232
 
236
233
  if optimization_goal is not None:
237
- changes['optimization_goal'] = optimization_goal
234
+ params['optimization_goal'] = optimization_goal
238
235
 
239
236
  if targeting is not None:
240
- # Get current ad set details to preserve existing targeting settings
241
- current_details_json = await get_adset_details(adset_id=adset_id, access_token=access_token)
242
- current_details = json.loads(current_details_json)
243
-
244
- # Check if the current ad set has targeting information
245
- current_targeting = current_details.get('targeting', {})
246
-
247
- if 'targeting_automation' in targeting:
248
- # Only update targeting_automation while preserving other targeting settings
249
- if current_targeting:
250
- merged_targeting = current_targeting.copy()
251
- merged_targeting['targeting_automation'] = targeting['targeting_automation']
252
- changes['targeting'] = merged_targeting
253
- else:
254
- # If there's no existing targeting, we need to create a basic one
255
- # Meta requires at least a geo_locations setting
256
- basic_targeting = {
257
- 'targeting_automation': targeting['targeting_automation'],
258
- 'geo_locations': {'countries': ['US']} # Using US as default location
259
- }
260
- changes['targeting'] = basic_targeting
237
+ # Ensure proper JSON encoding for targeting
238
+ if isinstance(targeting, dict):
239
+ params['targeting'] = json.dumps(targeting)
261
240
  else:
262
- # Full targeting replacement
263
- changes['targeting'] = targeting
241
+ params['targeting'] = targeting # Already a string
264
242
 
265
- if not changes:
243
+ if not params:
266
244
  return json.dumps({"error": "No update parameters provided"}, indent=2)
245
+
246
+ endpoint = f"{adset_id}"
267
247
 
268
- # Get current ad set details for comparison
269
- current_details_json = await get_adset_details(adset_id=adset_id, access_token=access_token)
270
- current_details = json.loads(current_details_json)
271
-
272
- # Start the callback server if not already running
273
248
  try:
274
- port = start_callback_server()
275
-
276
- # Generate confirmation URL with properly encoded parameters
277
- changes_json = json.dumps(changes)
278
- encoded_changes = urllib.parse.quote(changes_json)
279
- confirmation_url = f"http://localhost:{port}/confirm-update?adset_id={adset_id}&token={access_token}&changes={encoded_changes}"
249
+ # Use POST method for updates as per Meta API documentation
250
+ data = await make_api_request(endpoint, access_token, params, method="POST")
251
+ return json.dumps(data, indent=2)
280
252
  except Exception as e:
253
+ error_msg = str(e)
254
+ # Include adset_id in error for better context
281
255
  return json.dumps({
282
- "error": "Callback server disabled",
283
- "message": f"Cannot create confirmation URL: {str(e)}",
284
- "suggestion": "Manual update confirmation not available when META_ADS_DISABLE_CALLBACK_SERVER is set",
285
- "adset_id": adset_id,
286
- "proposed_changes": changes
287
- }, indent=2)
288
-
289
- # Reset the update confirmation
290
- update_confirmation.clear()
291
- update_confirmation.update({"approved": False})
292
-
293
- # Return the confirmation link
294
- response = {
295
- "message": "Please confirm the ad set update",
296
- "confirmation_url": confirmation_url,
297
- "markdown_link": f"[Click here to confirm ad set update]({confirmation_url})",
298
- "current_details": current_details,
299
- "proposed_changes": changes,
300
- "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
301
- "note": "Click the link to confirm and apply your ad set updates. Refresh the browser page if it doesn't load immediately."
302
- }
303
-
304
- return json.dumps(response, indent=2)
256
+ "error": f"Failed to update ad set {adset_id}",
257
+ "details": error_msg,
258
+ "params_sent": params
259
+ }, indent=2)
@@ -0,0 +1,257 @@
1
+ """Callback server for Meta Ads API authentication."""
2
+
3
+ import threading
4
+ import socket
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import webbrowser
9
+ import os
10
+ from http.server import HTTPServer, BaseHTTPRequestHandler
11
+ from urllib.parse import urlparse, parse_qs, quote
12
+ from typing import Dict, Any, Optional
13
+
14
+ from .utils import logger
15
+
16
+ # Global token container for communication between threads
17
+ token_container = {"token": None, "expires_in": None, "user_id": None}
18
+
19
+ # Global variables for server thread and state
20
+ callback_server_thread = None
21
+ callback_server_lock = threading.Lock()
22
+ callback_server_running = False
23
+ callback_server_port = None
24
+ callback_server_instance = None
25
+ server_shutdown_timer = None
26
+
27
+ # Timeout in seconds before shutting down the callback server
28
+ CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
29
+
30
+
31
+ class CallbackHandler(BaseHTTPRequestHandler):
32
+ def do_GET(self):
33
+ try:
34
+ # Print path for debugging
35
+ print(f"Callback server received request: {self.path}")
36
+
37
+ if self.path.startswith("/callback"):
38
+ self._handle_oauth_callback()
39
+ elif self.path.startswith("/token"):
40
+ self._handle_token()
41
+ else:
42
+ # If no matching path, return a 404 error
43
+ self.send_response(404)
44
+ self.end_headers()
45
+ except Exception as e:
46
+ print(f"Error processing request: {e}")
47
+ self.send_response(500)
48
+ self.end_headers()
49
+
50
+ def _handle_oauth_callback(self):
51
+ """Handle OAuth callback after user authorization"""
52
+ # Check if we're being redirected from Facebook with an authorization code
53
+ parsed_url = urlparse(self.path)
54
+ params = parse_qs(parsed_url.query)
55
+
56
+ # Check for code parameter
57
+ code = params.get('code', [None])[0]
58
+ state = params.get('state', [None])[0]
59
+ error = params.get('error', [None])[0]
60
+
61
+ # Send 200 OK response with a simple HTML page
62
+ self.send_response(200)
63
+ self.send_header("Content-type", "text/html")
64
+ self.end_headers()
65
+
66
+ if error:
67
+ # User denied access or other error occurred
68
+ html = f"""
69
+ <html>
70
+ <head><title>Authorization Failed</title></head>
71
+ <body>
72
+ <h1>Authorization Failed</h1>
73
+ <p>Error: {error}</p>
74
+ <p>The authorization was cancelled or failed. You can close this window.</p>
75
+ </body>
76
+ </html>
77
+ """
78
+ logger.error(f"OAuth authorization failed: {error}")
79
+ elif code:
80
+ # Success case - we have the authorization code
81
+ logger.info(f"Received authorization code: {code[:10]}...")
82
+
83
+ # Store the authorization code temporarily
84
+ # The auth module will exchange this for an access token
85
+ token_container.update({
86
+ "auth_code": code,
87
+ "state": state,
88
+ "timestamp": asyncio.get_event_loop().time()
89
+ })
90
+
91
+ html = """
92
+ <html>
93
+ <head><title>Authorization Successful</title></head>
94
+ <body>
95
+ <h1>✅ Authorization Successful!</h1>
96
+ <p>You have successfully authorized the Meta Ads MCP application.</p>
97
+ <p>You can now close this window and return to your application.</p>
98
+ <script>
99
+ // Try to close the window automatically after 2 seconds
100
+ setTimeout(function() {
101
+ window.close();
102
+ }, 2000);
103
+ </script>
104
+ </body>
105
+ </html>
106
+ """
107
+ logger.info("OAuth authorization successful")
108
+ else:
109
+ # No code or error - something unexpected happened
110
+ html = """
111
+ <html>
112
+ <head><title>Unexpected Response</title></head>
113
+ <body>
114
+ <h1>Unexpected Response</h1>
115
+ <p>No authorization code or error received. Please try again.</p>
116
+ </body>
117
+ </html>
118
+ """
119
+ logger.warning("OAuth callback received without code or error")
120
+
121
+ self.wfile.write(html.encode())
122
+
123
+ def _handle_token(self):
124
+ """Handle token endpoint for retrieving stored token data"""
125
+ # This endpoint allows other parts of the application to retrieve
126
+ # token information from the callback server
127
+
128
+ self.send_response(200)
129
+ self.send_header("Content-type", "application/json")
130
+ self.end_headers()
131
+
132
+ # Return current token container contents
133
+ response_data = {
134
+ "status": "success",
135
+ "data": token_container
136
+ }
137
+
138
+ self.wfile.write(json.dumps(response_data).encode())
139
+
140
+ # The actual token processing is now handled by the auth module
141
+ # that imports this module and accesses token_container
142
+
143
+ # Silence server logs
144
+ def log_message(self, format, *args):
145
+ return
146
+
147
+
148
+ def shutdown_callback_server():
149
+ """
150
+ Shutdown the callback server if it's running
151
+ """
152
+ global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
153
+
154
+ with callback_server_lock:
155
+ if not callback_server_running:
156
+ print("Callback server is not running")
157
+ return
158
+
159
+ if server_shutdown_timer is not None:
160
+ server_shutdown_timer.cancel()
161
+ server_shutdown_timer = None
162
+
163
+ try:
164
+ if callback_server_instance:
165
+ print("Shutting down callback server...")
166
+ callback_server_instance.shutdown()
167
+ callback_server_instance.server_close()
168
+ print("Callback server shut down successfully")
169
+
170
+ if callback_server_thread and callback_server_thread.is_alive():
171
+ callback_server_thread.join(timeout=5)
172
+ if callback_server_thread.is_alive():
173
+ print("Warning: Callback server thread did not shut down cleanly")
174
+ except Exception as e:
175
+ print(f"Error during callback server shutdown: {e}")
176
+ finally:
177
+ callback_server_running = False
178
+ callback_server_thread = None
179
+ callback_server_port = None
180
+ callback_server_instance = None
181
+
182
+
183
+ def start_callback_server() -> int:
184
+ """
185
+ Start the callback server and return the port number it's running on.
186
+
187
+ Returns:
188
+ int: Port number the server is listening on
189
+
190
+ Raises:
191
+ Exception: If the server fails to start
192
+ """
193
+ global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
194
+
195
+ # Check if callback server is disabled
196
+ if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
197
+ raise Exception("Callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
198
+
199
+ with callback_server_lock:
200
+ if callback_server_running:
201
+ print(f"Callback server already running on port {callback_server_port}")
202
+ return callback_server_port
203
+
204
+ # Find an available port
205
+ port = 8080
206
+ max_attempts = 10
207
+ for attempt in range(max_attempts):
208
+ try:
209
+ # Test if port is available
210
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
211
+ s.bind(('localhost', port))
212
+ break
213
+ except OSError:
214
+ port += 1
215
+ else:
216
+ raise Exception(f"Could not find an available port after {max_attempts} attempts")
217
+
218
+ callback_server_port = port
219
+
220
+ # Start the server in a separate thread
221
+ callback_server_thread = threading.Thread(target=server_thread, daemon=True)
222
+ callback_server_thread.start()
223
+
224
+ # Wait a moment for the server to start
225
+ import time
226
+ time.sleep(0.5)
227
+
228
+ if not callback_server_running:
229
+ raise Exception("Failed to start callback server")
230
+
231
+ # Set up automatic shutdown timer
232
+ def auto_shutdown():
233
+ print(f"Callback server auto-shutdown after {CALLBACK_SERVER_TIMEOUT} seconds")
234
+ shutdown_callback_server()
235
+
236
+ server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, auto_shutdown)
237
+ server_shutdown_timer.start()
238
+
239
+ print(f"Callback server started on http://localhost:{port}")
240
+ return port
241
+
242
+
243
+ def server_thread():
244
+ """Thread function to run the callback server"""
245
+ global callback_server_running, callback_server_instance
246
+
247
+ try:
248
+ callback_server_instance = HTTPServer(('localhost', callback_server_port), CallbackHandler)
249
+ callback_server_running = True
250
+ print(f"Callback server thread started on port {callback_server_port}")
251
+ callback_server_instance.serve_forever()
252
+ except Exception as e:
253
+ print(f"Callback server error: {e}")
254
+ callback_server_running = False
255
+ finally:
256
+ print("Callback server thread finished")
257
+ callback_server_running = False
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.4.3"
7
+ version = "0.4.4"
8
8
  description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"