meta-ads-mcp 0.4.0__py3-none-any.whl → 0.4.2__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.
meta_ads_mcp/__init__.py CHANGED
@@ -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.0"
10
+ __version__ = "0.4.2"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -12,6 +12,7 @@ from .auth import login
12
12
  from .ads_library import search_ads_archive
13
13
  from .budget_schedules import create_budget_schedule
14
14
  from . import reports # Import module to register conditional tools
15
+ from . import duplication # Import module to register conditional duplication tools
15
16
 
16
17
  __all__ = [
17
18
  'mcp_server',
@@ -270,12 +270,21 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
270
270
  current_details = json.loads(current_details_json)
271
271
 
272
272
  # Start the callback server if not already running
273
- port = start_callback_server()
274
-
275
- # Generate confirmation URL with properly encoded parameters
276
- changes_json = json.dumps(changes)
277
- encoded_changes = urllib.parse.quote(changes_json)
278
- confirmation_url = f"http://localhost:{port}/confirm-update?adset_id={adset_id}&token={access_token}&changes={encoded_changes}"
273
+ 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}"
280
+ except Exception as e:
281
+ 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)
279
288
 
280
289
  # Reset the update confirmation
281
290
  update_confirmation.clear()
meta_ads_mcp/core/auth.py CHANGED
@@ -216,22 +216,27 @@ class AuthManager:
216
216
  return self.token_info.access_token
217
217
 
218
218
  # Start the callback server if not already running
219
- port = start_callback_server()
220
-
221
- # Update redirect URI with the actual port
222
- self.redirect_uri = f"http://localhost:{port}/callback"
223
-
224
- # Generate the auth URL
225
- auth_url = self.get_auth_url()
226
-
227
- # Open browser with auth URL
228
- logger.info(f"Opening browser with URL: {auth_url}")
229
- webbrowser.open(auth_url)
230
-
231
- # We don't wait for the token here anymore
232
- # The token will be processed by the callback server
233
- # Just return None to indicate we've started the flow
234
- return None
219
+ try:
220
+ port = start_callback_server()
221
+
222
+ # Update redirect URI with the actual port
223
+ self.redirect_uri = f"http://localhost:{port}/callback"
224
+
225
+ # Generate the auth URL
226
+ auth_url = self.get_auth_url()
227
+
228
+ # Open browser with auth URL
229
+ logger.info(f"Opening browser with URL: {auth_url}")
230
+ webbrowser.open(auth_url)
231
+
232
+ # We don't wait for the token here anymore
233
+ # The token will be processed by the callback server
234
+ # Just return None to indicate we've started the flow
235
+ return None
236
+ except Exception as e:
237
+ logger.error(f"Failed to start callback server: {e}")
238
+ logger.info("Callback server disabled. OAuth authentication flow cannot be used.")
239
+ return None
235
240
 
236
241
  def get_access_token(self) -> Optional[str]:
237
242
  """
@@ -477,7 +482,14 @@ def login():
477
482
 
478
483
  try:
479
484
  # Start the callback server first
480
- port = start_callback_server()
485
+ try:
486
+ port = start_callback_server()
487
+ except Exception as callback_error:
488
+ print(f"Error: {callback_error}")
489
+ print("Callback server is disabled. Please use alternative authentication methods:")
490
+ print("- Set PIPEBOARD_API_TOKEN environment variable for Pipeboard authentication")
491
+ print("- Or provide a direct META_ACCESS_TOKEN environment variable")
492
+ return
481
493
 
482
494
  # Get the auth URL and open the browser
483
495
  auth_url = auth_manager.get_auth_url()
@@ -90,14 +90,23 @@ async def get_login_link(access_token: str = None) -> str:
90
90
  # IMPORTANT: Start the callback server first by calling our helper function
91
91
  # This ensures the server is ready before we provide the URL to the user
92
92
  logger.info("Starting callback server for authentication")
93
- port = start_callback_server()
94
- logger.info(f"Callback server started on port {port}")
95
-
96
- # Generate direct login URL
97
- auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
98
- logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
99
- login_url = auth_manager.get_auth_url()
100
- logger.info(f"Generated login URL: {login_url}")
93
+ try:
94
+ port = start_callback_server()
95
+ logger.info(f"Callback server started on port {port}")
96
+
97
+ # Generate direct login URL
98
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
99
+ logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
100
+ login_url = auth_manager.get_auth_url()
101
+ logger.info(f"Generated login URL: {login_url}")
102
+ except Exception as e:
103
+ logger.error(f"Failed to start callback server: {e}")
104
+ return json.dumps({
105
+ "error": "Callback server disabled",
106
+ "message": str(e),
107
+ "suggestion": "Use Pipeboard authentication (set PIPEBOARD_API_TOKEN) or provide a direct access token",
108
+ "authentication_method": "meta_oauth_disabled"
109
+ }, indent=2)
101
110
 
102
111
  # Check if we can exchange for long-lived tokens
103
112
  token_exchange_supported = bool(META_APP_SECRET)
@@ -917,7 +917,16 @@ def start_callback_server() -> int:
917
917
 
918
918
  Returns:
919
919
  Port number the server is running on
920
+
921
+ Raises:
922
+ Exception: If callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable
920
923
  """
924
+ # Check if callback server is disabled via environment variable
925
+ if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
926
+ logger.info("Callback server disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
927
+ print("Callback server is disabled. OAuth authentication flow cannot be used.")
928
+ raise Exception("Callback server disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable. Use alternative authentication methods.")
929
+
921
930
  global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
922
931
 
923
932
  with callback_server_lock:
@@ -0,0 +1,443 @@
1
+ """Duplication functionality for Meta Ads API."""
2
+
3
+ import json
4
+ import os
5
+ import httpx
6
+ from typing import Optional, Dict, Any, List, Union
7
+ from .server import mcp_server
8
+ from .api import meta_api_tool
9
+ from .auth import get_current_access_token
10
+ from .http_auth_integration import FastMCPAuthIntegration
11
+
12
+
13
+ # Only register the duplication functions if the environment variable is set
14
+ ENABLE_DUPLICATION = bool(os.environ.get("META_ADS_ENABLE_DUPLICATION", ""))
15
+
16
+ if ENABLE_DUPLICATION:
17
+ @mcp_server.tool()
18
+ @meta_api_tool
19
+ async def duplicate_campaign(
20
+ campaign_id: str,
21
+ access_token: str = None,
22
+ name_suffix: Optional[str] = " - Copy",
23
+ include_ad_sets: bool = True,
24
+ include_ads: bool = True,
25
+ include_creatives: bool = True,
26
+ copy_schedule: bool = False,
27
+ new_daily_budget: Optional[float] = None,
28
+ new_status: Optional[str] = "PAUSED"
29
+ ) -> str:
30
+ """
31
+ Duplicate a Meta Ads campaign with all its ad sets and ads.
32
+
33
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
34
+
35
+ Args:
36
+ campaign_id: Meta Ads campaign ID to duplicate
37
+ name_suffix: Suffix to add to the duplicated campaign name
38
+ include_ad_sets: Whether to duplicate ad sets within the campaign
39
+ include_ads: Whether to duplicate ads within ad sets
40
+ include_creatives: Whether to duplicate ad creatives
41
+ copy_schedule: Whether to copy the campaign schedule
42
+ new_daily_budget: Override the daily budget for the new campaign
43
+ new_status: Status for the new campaign (ACTIVE or PAUSED)
44
+ """
45
+ return await _forward_duplication_request(
46
+ "campaign",
47
+ campaign_id,
48
+ access_token,
49
+ {
50
+ "name_suffix": name_suffix,
51
+ "include_ad_sets": include_ad_sets,
52
+ "include_ads": include_ads,
53
+ "include_creatives": include_creatives,
54
+ "copy_schedule": copy_schedule,
55
+ "new_daily_budget": new_daily_budget,
56
+ "new_status": new_status
57
+ }
58
+ )
59
+
60
+ @mcp_server.tool()
61
+ @meta_api_tool
62
+ async def duplicate_adset(
63
+ adset_id: str,
64
+ access_token: str = None,
65
+ target_campaign_id: Optional[str] = None,
66
+ name_suffix: Optional[str] = " - Copy",
67
+ include_ads: bool = True,
68
+ include_creatives: bool = True,
69
+ new_daily_budget: Optional[float] = None,
70
+ new_targeting: Optional[Dict[str, Any]] = None,
71
+ new_status: Optional[str] = "PAUSED"
72
+ ) -> str:
73
+ """
74
+ Duplicate a Meta Ads ad set with its ads.
75
+
76
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
77
+
78
+ Args:
79
+ adset_id: Meta Ads ad set ID to duplicate
80
+ target_campaign_id: Campaign ID to move the duplicated ad set to (optional)
81
+ name_suffix: Suffix to add to the duplicated ad set name
82
+ include_ads: Whether to duplicate ads within the ad set
83
+ include_creatives: Whether to duplicate ad creatives
84
+ new_daily_budget: Override the daily budget for the new ad set
85
+ new_targeting: Override targeting settings for the new ad set
86
+ new_status: Status for the new ad set (ACTIVE or PAUSED)
87
+ """
88
+ return await _forward_duplication_request(
89
+ "adset",
90
+ adset_id,
91
+ access_token,
92
+ {
93
+ "target_campaign_id": target_campaign_id,
94
+ "name_suffix": name_suffix,
95
+ "include_ads": include_ads,
96
+ "include_creatives": include_creatives,
97
+ "new_daily_budget": new_daily_budget,
98
+ "new_targeting": new_targeting,
99
+ "new_status": new_status
100
+ }
101
+ )
102
+
103
+ @mcp_server.tool()
104
+ @meta_api_tool
105
+ async def duplicate_ad(
106
+ ad_id: str,
107
+ access_token: str = None,
108
+ target_adset_id: Optional[str] = None,
109
+ name_suffix: Optional[str] = " - Copy",
110
+ duplicate_creative: bool = True,
111
+ new_creative_name: Optional[str] = None,
112
+ new_status: Optional[str] = "PAUSED"
113
+ ) -> str:
114
+ """
115
+ Duplicate a Meta Ads ad.
116
+
117
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
118
+
119
+ Args:
120
+ ad_id: Meta Ads ad ID to duplicate
121
+ target_adset_id: Ad set ID to move the duplicated ad to (optional)
122
+ name_suffix: Suffix to add to the duplicated ad name
123
+ duplicate_creative: Whether to duplicate the ad creative
124
+ new_creative_name: Override name for the duplicated creative
125
+ new_status: Status for the new ad (ACTIVE or PAUSED)
126
+ """
127
+ return await _forward_duplication_request(
128
+ "ad",
129
+ ad_id,
130
+ access_token,
131
+ {
132
+ "target_adset_id": target_adset_id,
133
+ "name_suffix": name_suffix,
134
+ "duplicate_creative": duplicate_creative,
135
+ "new_creative_name": new_creative_name,
136
+ "new_status": new_status
137
+ }
138
+ )
139
+
140
+ @mcp_server.tool()
141
+ @meta_api_tool
142
+ async def duplicate_creative(
143
+ creative_id: str,
144
+ access_token: str = None,
145
+ name_suffix: Optional[str] = " - Copy",
146
+ new_primary_text: Optional[str] = None,
147
+ new_headline: Optional[str] = None,
148
+ new_description: Optional[str] = None,
149
+ new_cta_type: Optional[str] = None,
150
+ new_destination_url: Optional[str] = None
151
+ ) -> str:
152
+ """
153
+ Duplicate a Meta Ads creative.
154
+
155
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
156
+
157
+ Args:
158
+ creative_id: Meta Ads creative ID to duplicate
159
+ name_suffix: Suffix to add to the duplicated creative name
160
+ new_primary_text: Override the primary text for the new creative
161
+ new_headline: Override the headline for the new creative
162
+ new_description: Override the description for the new creative
163
+ new_cta_type: Override the call-to-action type for the new creative
164
+ new_destination_url: Override the destination URL for the new creative
165
+ """
166
+ return await _forward_duplication_request(
167
+ "creative",
168
+ creative_id,
169
+ access_token,
170
+ {
171
+ "name_suffix": name_suffix,
172
+ "new_primary_text": new_primary_text,
173
+ "new_headline": new_headline,
174
+ "new_description": new_description,
175
+ "new_cta_type": new_cta_type,
176
+ "new_destination_url": new_destination_url
177
+ }
178
+ )
179
+
180
+
181
+ async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
182
+ """
183
+ Forward duplication request to the cloud-hosted MCP API using dual-header authentication.
184
+
185
+ This implements the dual-header authentication pattern for MCP server callbacks:
186
+ - Authorization: Bearer <facebook_token> - Facebook access token for Meta API calls
187
+ - X-Pipeboard-Token: <pipeboard_token> - Pipeboard API token for authentication
188
+
189
+ Args:
190
+ resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
191
+ resource_id: ID of the resource to duplicate
192
+ access_token: Meta API access token (optional, will use context if not provided)
193
+ options: Duplication options
194
+ """
195
+ try:
196
+ # Get tokens from the request context that were set by the HTTP auth middleware
197
+ # In the dual-header authentication pattern:
198
+ # - Pipeboard token comes from X-Pipeboard-Token header (for authentication)
199
+ # - Facebook token comes from Authorization header (for Meta API calls)
200
+
201
+ # Get tokens from context set by AuthInjectionMiddleware
202
+ pipeboard_token = FastMCPAuthIntegration.get_pipeboard_token()
203
+ facebook_token = FastMCPAuthIntegration.get_auth_token()
204
+
205
+ # Use provided access_token parameter if no Facebook token found in context
206
+ if not facebook_token:
207
+ facebook_token = access_token if access_token else await get_current_access_token()
208
+
209
+ # Validate we have both required tokens
210
+ if not pipeboard_token:
211
+ return json.dumps({
212
+ "error": "authentication_required",
213
+ "message": "Pipeboard API token not found",
214
+ "details": {
215
+ "required": "Valid Pipeboard token via X-Pipeboard-Token header",
216
+ "received_headers": "Check that the MCP server is forwarding the X-Pipeboard-Token header"
217
+ }
218
+ }, indent=2)
219
+
220
+ if not facebook_token:
221
+ return json.dumps({
222
+ "error": "authentication_required",
223
+ "message": "Meta Ads access token not found",
224
+ "details": {
225
+ "required": "Valid Meta access token from authenticated session",
226
+ "check": "Ensure Facebook account is connected and token is valid"
227
+ }
228
+ }, indent=2)
229
+
230
+ # Construct the API endpoint
231
+ base_url = "https://mcp.pipeboard.co"
232
+ endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
233
+
234
+ # Prepare the dual-header authentication as per API documentation
235
+ headers = {
236
+ "Authorization": f"Bearer {facebook_token}", # Facebook token for Meta API
237
+ "X-Pipeboard-Token": pipeboard_token, # Pipeboard token for auth
238
+ "Content-Type": "application/json",
239
+ "User-Agent": "meta-ads-mcp/1.0"
240
+ }
241
+
242
+ # Remove None values from options
243
+ clean_options = {k: v for k, v in options.items() if v is not None}
244
+
245
+ # Make the request to the cloud service
246
+ async with httpx.AsyncClient(timeout=30.0) as client:
247
+ response = await client.post(
248
+ endpoint,
249
+ headers=headers,
250
+ json=clean_options
251
+ )
252
+
253
+ if response.status_code == 200:
254
+ result = response.json()
255
+ return json.dumps(result, indent=2)
256
+ elif response.status_code == 400:
257
+ # Validation failed
258
+ try:
259
+ error_data = response.json()
260
+ return json.dumps({
261
+ "success": False,
262
+ "error": "validation_failed",
263
+ "errors": error_data.get("errors", [response.text]),
264
+ "warnings": error_data.get("warnings", [])
265
+ }, indent=2)
266
+ except:
267
+ return json.dumps({
268
+ "success": False,
269
+ "error": "validation_failed",
270
+ "errors": [response.text],
271
+ "warnings": []
272
+ }, indent=2)
273
+ elif response.status_code == 401:
274
+ return json.dumps({
275
+ "success": False,
276
+ "error": "authentication_error",
277
+ "message": "Invalid or expired API token"
278
+ }, indent=2)
279
+ elif response.status_code == 402:
280
+ try:
281
+ error_data = response.json()
282
+ return json.dumps({
283
+ "success": False,
284
+ "error": "subscription_required",
285
+ "message": error_data.get("message", "This feature is not available in your current plan"),
286
+ "upgrade_url": error_data.get("upgrade_url", "https://pipeboard.co/upgrade"),
287
+ "suggestion": error_data.get("suggestion", "Please upgrade your account to access this feature")
288
+ }, indent=2)
289
+ except:
290
+ return json.dumps({
291
+ "success": False,
292
+ "error": "subscription_required",
293
+ "message": "This feature is not available in your current plan",
294
+ "upgrade_url": "https://pipeboard.co/upgrade",
295
+ "suggestion": "Please upgrade your account to access this feature"
296
+ }, indent=2)
297
+ elif response.status_code == 403:
298
+ try:
299
+ error_data = response.json()
300
+ # Check if this is a premium feature error
301
+ if error_data.get("error") == "premium_feature":
302
+ return json.dumps({
303
+ "success": False,
304
+ "error": "premium_feature_required",
305
+ "message": error_data.get("message", "This is a premium feature that requires subscription"),
306
+ "details": error_data.get("details", {
307
+ "upgrade_url": "https://pipeboard.co/upgrade",
308
+ "suggestion": "Please upgrade your account to access this feature"
309
+ })
310
+ }, indent=2)
311
+ else:
312
+ # Default to facebook connection required
313
+ return json.dumps({
314
+ "success": False,
315
+ "error": "facebook_connection_required",
316
+ "message": error_data.get("message", "You need to connect your Facebook account first"),
317
+ "details": error_data.get("details", {
318
+ "login_flow_url": "/connections",
319
+ "auth_flow_url": "/api/meta/auth"
320
+ })
321
+ }, indent=2)
322
+ except:
323
+ return json.dumps({
324
+ "success": False,
325
+ "error": "facebook_connection_required",
326
+ "message": "You need to connect your Facebook account first",
327
+ "details": {
328
+ "login_flow_url": "/connections",
329
+ "auth_flow_url": "/api/meta/auth"
330
+ }
331
+ }, indent=2)
332
+ elif response.status_code == 404:
333
+ return json.dumps({
334
+ "success": False,
335
+ "error": "resource_not_found",
336
+ "message": f"{resource_type.title()} not found or access denied",
337
+ "suggestion": f"Verify the {resource_type} ID and your Facebook account permissions"
338
+ }, indent=2)
339
+ elif response.status_code == 429:
340
+ return json.dumps({
341
+ "error": "rate_limit_exceeded",
342
+ "message": "Meta API rate limit exceeded",
343
+ "details": {
344
+ "suggestion": "Please wait before retrying",
345
+ "retry_after": response.headers.get("Retry-After", "60")
346
+ }
347
+ }, indent=2)
348
+ elif response.status_code == 502:
349
+ try:
350
+ error_data = response.json()
351
+ return json.dumps({
352
+ "success": False,
353
+ "error": "meta_api_error",
354
+ "message": error_data.get("message", "Facebook API error"),
355
+ "recoverable": True,
356
+ "suggestion": "Please wait 5 minutes before retrying"
357
+ }, indent=2)
358
+ except:
359
+ return json.dumps({
360
+ "success": False,
361
+ "error": "meta_api_error",
362
+ "message": "Facebook API error",
363
+ "recoverable": True,
364
+ "suggestion": "Please wait 5 minutes before retrying"
365
+ }, indent=2)
366
+ else:
367
+ error_detail = response.text
368
+ try:
369
+ error_json = response.json()
370
+ error_detail = error_json.get("message", error_detail)
371
+ except:
372
+ pass
373
+
374
+ return json.dumps({
375
+ "error": "duplication_failed",
376
+ "message": f"Failed to duplicate {resource_type}",
377
+ "details": {
378
+ "status_code": response.status_code,
379
+ "error_detail": error_detail,
380
+ "resource_type": resource_type,
381
+ "resource_id": resource_id
382
+ }
383
+ }, indent=2)
384
+
385
+ except httpx.TimeoutException:
386
+ return json.dumps({
387
+ "error": "request_timeout",
388
+ "message": "Request to duplication service timed out",
389
+ "details": {
390
+ "suggestion": "Please try again later",
391
+ "timeout": "30 seconds"
392
+ }
393
+ }, indent=2)
394
+
395
+ except httpx.RequestError as e:
396
+ return json.dumps({
397
+ "error": "network_error",
398
+ "message": "Failed to connect to duplication service",
399
+ "details": {
400
+ "error": str(e),
401
+ "suggestion": "Check your internet connection and try again"
402
+ }
403
+ }, indent=2)
404
+
405
+ except Exception as e:
406
+ return json.dumps({
407
+ "error": "unexpected_error",
408
+ "message": f"Unexpected error during {resource_type} duplication",
409
+ "details": {
410
+ "error": str(e),
411
+ "resource_type": resource_type,
412
+ "resource_id": resource_id
413
+ }
414
+ }, indent=2)
415
+
416
+
417
+ def _get_estimated_components(resource_type: str, options: Dict[str, Any]) -> Dict[str, Any]:
418
+ """Get estimated components that would be duplicated."""
419
+ if resource_type == "campaign":
420
+ components = {"campaigns": 1}
421
+ if options.get("include_ad_sets", True):
422
+ components["ad_sets"] = "3-5 (estimated)"
423
+ if options.get("include_ads", True):
424
+ components["ads"] = "5-15 (estimated)"
425
+ if options.get("include_creatives", True):
426
+ components["creatives"] = "5-15 (estimated)"
427
+ return components
428
+ elif resource_type == "adset":
429
+ components = {"ad_sets": 1}
430
+ if options.get("include_ads", True):
431
+ components["ads"] = "2-5 (estimated)"
432
+ if options.get("include_creatives", True):
433
+ components["creatives"] = "2-5 (estimated)"
434
+ return components
435
+ elif resource_type == "ad":
436
+ components = {"ads": 1}
437
+ if options.get("duplicate_creative", True):
438
+ components["creatives"] = 1
439
+ return components
440
+ elif resource_type == "creative":
441
+ return {"creatives": 1}
442
+
443
+ return {}
@@ -13,6 +13,7 @@ import json
13
13
 
14
14
  # Use context variables instead of thread-local storage for better async support
15
15
  _auth_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('auth_token', default=None)
16
+ _pipeboard_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('pipeboard_token', default=None)
16
17
 
17
18
  class FastMCPAuthIntegration:
18
19
  """Direct integration with FastMCP for HTTP authentication"""
@@ -35,11 +36,34 @@ class FastMCPAuthIntegration:
35
36
  """
36
37
  return _auth_token.get(None)
37
38
 
39
+ @staticmethod
40
+ def set_pipeboard_token(token: str) -> None:
41
+ """Set Pipeboard token for the current context
42
+
43
+ Args:
44
+ token: Pipeboard API token to use for this request
45
+ """
46
+ _pipeboard_token.set(token)
47
+
48
+ @staticmethod
49
+ def get_pipeboard_token() -> Optional[str]:
50
+ """Get Pipeboard token for the current context
51
+
52
+ Returns:
53
+ Pipeboard token if set, None otherwise
54
+ """
55
+ return _pipeboard_token.get(None)
56
+
38
57
  @staticmethod
39
58
  def clear_auth_token() -> None:
40
59
  """Clear authentication token for the current context"""
41
60
  _auth_token.set(None)
42
61
 
62
+ @staticmethod
63
+ def clear_pipeboard_token() -> None:
64
+ """Clear Pipeboard token for the current context"""
65
+ _pipeboard_token.set(None)
66
+
43
67
  @staticmethod
44
68
  def extract_token_from_headers(headers: dict) -> Optional[str]:
45
69
  """Extract token from HTTP headers
@@ -69,6 +93,30 @@ class FastMCPAuthIntegration:
69
93
  return pipeboard_token
70
94
 
71
95
  return None
96
+
97
+ @staticmethod
98
+ def extract_pipeboard_token_from_headers(headers: dict) -> Optional[str]:
99
+ """Extract Pipeboard token from HTTP headers
100
+
101
+ Args:
102
+ headers: HTTP request headers
103
+
104
+ Returns:
105
+ Pipeboard token if found, None otherwise
106
+ """
107
+ # Check for Pipeboard token in X-Pipeboard-Token header (duplication API pattern)
108
+ pipeboard_token = headers.get('X-Pipeboard-Token') or headers.get('x-pipeboard-token')
109
+ if pipeboard_token:
110
+ logger.debug("Found Pipeboard token in X-Pipeboard-Token header")
111
+ return pipeboard_token
112
+
113
+ # Check for legacy Pipeboard token header
114
+ legacy_token = headers.get('X-PIPEBOARD-API-TOKEN') or headers.get('x-pipeboard-api-token')
115
+ if legacy_token:
116
+ logger.debug("Found Pipeboard token in legacy X-PIPEBOARD-API-TOKEN header")
117
+ return legacy_token
118
+
119
+ return None
72
120
 
73
121
  def patch_fastmcp_server(mcp_server):
74
122
  """Patch FastMCP server to inject authentication from HTTP headers
@@ -203,21 +251,32 @@ class AuthInjectionMiddleware(BaseHTTPMiddleware):
203
251
  logger.debug(f"HTTP Auth Middleware: Processing request to {request.url.path}")
204
252
  logger.debug(f"HTTP Auth Middleware: Request headers: {list(request.headers.keys())}")
205
253
 
206
- token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))
254
+ # Extract both types of tokens for dual-header authentication
255
+ auth_token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))
256
+ pipeboard_token = FastMCPAuthIntegration.extract_pipeboard_token_from_headers(dict(request.headers))
207
257
 
208
- if token:
209
- logger.debug(f"HTTP Auth Middleware: Extracted token: {token[:10]}...")
258
+ if auth_token:
259
+ logger.debug(f"HTTP Auth Middleware: Extracted auth token: {auth_token[:10]}...")
210
260
  logger.debug("Injecting auth token into request context")
211
- FastMCPAuthIntegration.set_auth_token(token)
212
- else:
213
- logger.warning("HTTP Auth Middleware: No authentication token found in headers")
261
+ FastMCPAuthIntegration.set_auth_token(auth_token)
262
+
263
+ if pipeboard_token:
264
+ logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {pipeboard_token[:10]}...")
265
+ logger.debug("Injecting Pipeboard token into request context")
266
+ FastMCPAuthIntegration.set_pipeboard_token(pipeboard_token)
267
+
268
+ if not auth_token and not pipeboard_token:
269
+ logger.warning("HTTP Auth Middleware: No authentication tokens found in headers")
214
270
 
215
271
  try:
216
272
  response = await call_next(request)
217
273
  return response
218
274
  finally:
219
- if token: # Clear only if a token was set for this request
275
+ # Clear tokens that were set for this request
276
+ if auth_token:
220
277
  FastMCPAuthIntegration.clear_auth_token()
278
+ if pipeboard_token:
279
+ FastMCPAuthIntegration.clear_pipeboard_token()
221
280
 
222
281
  def setup_starlette_middleware(app):
223
282
  """Add AuthInjectionMiddleware to the Starlette app if not already present.
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.4.0
4
- Summary: Model Calling Protocol (MCP) plugin for interacting with Meta Ads API
3
+ Version: 0.4.2
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
  Author-email: Yves Junqueira <yves.junqueira@gmail.com>
8
- License: MIT
8
+ License: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Keywords: ads,api,claude,facebook,mcp,meta
11
- Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.10
@@ -16,6 +16,8 @@ Requires-Dist: httpx>=0.26.0
16
16
  Requires-Dist: mcp[cli]>=1.10.1
17
17
  Requires-Dist: pathlib>=1.0.1
18
18
  Requires-Dist: pillow>=10.0.0
19
+ Requires-Dist: pytest-asyncio>=1.0.0
20
+ Requires-Dist: pytest>=8.4.1
19
21
  Requires-Dist: python-dateutil>=2.8.2
20
22
  Requires-Dist: python-dotenv>=1.1.0
21
23
  Requires-Dist: requests>=2.32.3
@@ -63,17 +65,14 @@ That's it! You can now ask Claude to analyze your Meta ad campaigns, get perform
63
65
 
64
66
  ### For Cursor Users
65
67
 
66
- Add this to your `~/.cursor/mcp.json`:
68
+ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
69
+
67
70
 
68
71
  ```json
69
72
  {
70
73
  "mcpServers": {
71
74
  "meta-ads-remote": {
72
- "command": "npx",
73
- "args": [
74
- "mcp-remote",
75
- "https://mcp.pipeboard.co/meta-ads-mcp"
76
- ]
75
+ "url": "https://mcp.pipeboard.co/meta-ads-mcp"
77
76
  }
78
77
  }
79
78
  }
@@ -1,25 +1,26 @@
1
- meta_ads_mcp/__init__.py,sha256=Ive8lkiVBTI71DtD75td68TiZ597_Br6xiYdVIbJaw0,1182
1
+ meta_ads_mcp/__init__.py,sha256=r7i4rteDOpqUZK4ZZkN5oihxeyUjOIAx-0xIyMqwPkU,1182
2
2
  meta_ads_mcp/__main__.py,sha256=XaQt3iXftG_7f0Zu7Wop9SeFgrD2WBn0EQOaPMc27d8,207
3
- meta_ads_mcp/core/__init__.py,sha256=TQSAEn_c_n5ShXcgPXbzDNfIfiPUgJAGe3VIUvSyvjo,1124
3
+ meta_ads_mcp/core/__init__.py,sha256=XVJjMOfdgnqxy3k8vCn2PCf7za8fMk4BdgJGiSFCVZY,1209
4
4
  meta_ads_mcp/core/accounts.py,sha256=Nmp7lPxO9wmq25jWV7_H0LIqnEbBhpCVBlLGW2HUaq0,2277
5
5
  meta_ads_mcp/core/ads.py,sha256=b_81GlGHIM4jISvuDZmHNyc6uW7uD3ovX68ezBci9MM,29747
6
6
  meta_ads_mcp/core/ads_library.py,sha256=onStn9UkRqYDC60gOPS-iKDtP1plz6DygUb7hUZ0Jw8,2807
7
- meta_ads_mcp/core/adsets.py,sha256=WBPNaI7ITnUOnGMus4_0MX15DslOCzfM5q1zF1VWs2s,12408
7
+ meta_ads_mcp/core/adsets.py,sha256=8m8RDsa1CmCb75-YXcMHDYNpa8J12ovtQPp0kgYHDk4,12823
8
8
  meta_ads_mcp/core/api.py,sha256=aAzM6Q75VQOFXtr5D-mDmBRhxWK4wsiODsJYnR3mpDI,14994
9
- meta_ads_mcp/core/auth.py,sha256=pDARBh3NBNqCpxflVrVvR4VsWuIveFxQmb9-P-gLFDM,20730
10
- meta_ads_mcp/core/authentication.py,sha256=PFqmN7ujtNsJCEDutDzs81peGWFJ8_0YLYI3-o8kmt4,6577
9
+ meta_ads_mcp/core/auth.py,sha256=z8HfLbDNB7IzoIcqt-lBGne6P97FF-dubO_cZNe5S_8,21425
10
+ meta_ads_mcp/core/authentication.py,sha256=4CH2Fe3w7Al7YE2wgoa0DW5qOXTp_5Lsa4T6_Rh55s0,7048
11
11
  meta_ads_mcp/core/budget_schedules.py,sha256=UxseExsvKAiPwfDCY9aycT4kys4xqeNytyq-yyDOxrs,2901
12
- meta_ads_mcp/core/callback_server.py,sha256=AUymElaVwHqFyqB2wgqf6A68KsqwtKoYmY-7JZZt8Ks,43286
12
+ meta_ads_mcp/core/callback_server.py,sha256=wNuxmj7YTFeSdVGi_iJ9vberNy3VdzBIP0uSsqn7g5Q,43888
13
13
  meta_ads_mcp/core/campaigns.py,sha256=Fd477GsD1Gx08Ve0uXUCvr4fC-xQCeVHPBwRVaeRQKk,10965
14
- meta_ads_mcp/core/http_auth_integration.py,sha256=ZJHuxK1Kwtr9gvwfC5HZOLH5MW-HnDDKqJc4xuG5yVE,10060
14
+ meta_ads_mcp/core/duplication.py,sha256=UUmTDFx9o5ZsPQG2Rb9c4ZyuKUVN3FfTjebfTIHHdo4,18984
15
+ meta_ads_mcp/core/http_auth_integration.py,sha256=lGpKhfzJcyWugBcYEvypY-qnlt-3UDBLqh7xAUH0DGw,12473
15
16
  meta_ads_mcp/core/insights.py,sha256=U7KYdWQpGcdykE1WUtdJdYR3VTwKrXUzIzCREwWbf48,2599
16
17
  meta_ads_mcp/core/pipeboard_auth.py,sha256=VvbxEB8ZOhnMccLU7HI1HgaPWHCl5NGrzZCm-zzHze4,22798
17
18
  meta_ads_mcp/core/reports.py,sha256=Dv3hfsPOR7IZ9WrYrKd_6SNgZl-USIphg7knva3UYAw,5747
18
19
  meta_ads_mcp/core/resources.py,sha256=-zIIfZulpo76vcKv6jhAlQq91cR2SZ3cjYZt3ek3x0w,1236
19
20
  meta_ads_mcp/core/server.py,sha256=mmhtcyB7h1aO6jK4njLztPdAebPDmc3mhA7DksR1nlY,17583
20
21
  meta_ads_mcp/core/utils.py,sha256=DsizDYuJnWUpkbShV1y5Qe8t47Qf59aPZ6O9v0hzdkY,6705
21
- meta_ads_mcp-0.4.0.dist-info/METADATA,sha256=qYEgWGno8pVxWx7qV9vK3GZwDsJhhkRTfX6dWaMeZ7M,17125
22
- meta_ads_mcp-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- meta_ads_mcp-0.4.0.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
24
- meta_ads_mcp-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
25
- meta_ads_mcp-0.4.0.dist-info/RECORD,,
22
+ meta_ads_mcp-0.4.2.dist-info/METADATA,sha256=M2VY7kNALGcXOYYjvwkxbmp3Ced9m3JO_hzAkjTxEQU,17239
23
+ meta_ads_mcp-0.4.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
+ meta_ads_mcp-0.4.2.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
25
+ meta_ads_mcp-0.4.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
26
+ meta_ads_mcp-0.4.2.dist-info/RECORD,,