meta-ads-mcp 0.4.0__tar.gz → 0.4.1__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 (50) hide show
  1. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/PKG-INFO +9 -10
  2. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/README.md +3 -6
  3. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/__init__.py +1 -1
  4. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/__init__.py +1 -0
  5. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/adsets.py +15 -6
  6. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/auth.py +29 -17
  7. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/authentication.py +17 -8
  8. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/callback_server.py +9 -0
  9. meta_ads_mcp-0.4.1/meta_ads_mcp/core/duplication.py +411 -0
  10. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/pyproject.toml +6 -4
  11. meta_ads_mcp-0.4.1/tests/README_REGRESSION_TESTS.md +185 -0
  12. meta_ads_mcp-0.4.1/tests/test_duplication.py +136 -0
  13. meta_ads_mcp-0.4.1/tests/test_duplication_regression.py +805 -0
  14. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.github/workflows/publish.yml +0 -0
  15. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.github/workflows/test.yml +0 -0
  16. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.gitignore +0 -0
  17. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/CUSTOM_META_APP.md +0 -0
  18. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/Dockerfile +0 -0
  19. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/LICENSE +0 -0
  20. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/LOCAL_INSTALLATION.md +0 -0
  21. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/META_API_NOTES.md +0 -0
  22. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/RELEASE.md +0 -0
  23. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/STREAMABLE_HTTP_SETUP.md +0 -0
  24. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/examples/README.md +0 -0
  25. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/examples/example_http_client.py +0 -0
  26. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/future_improvements.md +0 -0
  27. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/images/meta-ads-example.png +0 -0
  28. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_auth.sh +0 -0
  29. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/__main__.py +0 -0
  30. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/accounts.py +0 -0
  31. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/ads.py +0 -0
  32. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/ads_library.py +0 -0
  33. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/api.py +0 -0
  34. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/budget_schedules.py +0 -0
  35. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/campaigns.py +0 -0
  36. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  37. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/insights.py +0 -0
  38. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  39. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/reports.py +0 -0
  40. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/resources.py +0 -0
  41. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/server.py +0 -0
  42. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/utils.py +0 -0
  43. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/requirements.txt +0 -0
  44. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/setup.py +0 -0
  45. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/smithery.yaml +0 -0
  46. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/README.md +0 -0
  47. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/__init__.py +0 -0
  48. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/conftest.py +0 -0
  49. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/test_http_transport.py +0 -0
  50. {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/test_openai.py +0 -0
@@ -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.1
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
  }
@@ -40,17 +40,14 @@ That's it! You can now ask Claude to analyze your Meta ad campaigns, get perform
40
40
 
41
41
  ### For Cursor Users
42
42
 
43
- Add this to your `~/.cursor/mcp.json`:
43
+ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
44
+
44
45
 
45
46
  ```json
46
47
  {
47
48
  "mcpServers": {
48
49
  "meta-ads-remote": {
49
- "command": "npx",
50
- "args": [
51
- "mcp-remote",
52
- "https://mcp.pipeboard.co/meta-ads-mcp"
53
- ]
50
+ "url": "https://mcp.pipeboard.co/meta-ads-mcp"
54
51
  }
55
52
  }
56
53
  }
@@ -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.1"
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()
@@ -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,411 @@
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
+
10
+
11
+ # Only register the duplication functions if the environment variable is set
12
+ ENABLE_DUPLICATION = bool(os.environ.get("META_ADS_ENABLE_DUPLICATION", ""))
13
+
14
+ if ENABLE_DUPLICATION:
15
+ @mcp_server.tool()
16
+ @meta_api_tool
17
+ async def duplicate_campaign(
18
+ campaign_id: str,
19
+ access_token: str = None,
20
+ name_suffix: Optional[str] = " - Copy",
21
+ include_ad_sets: bool = True,
22
+ include_ads: bool = True,
23
+ include_creatives: bool = True,
24
+ copy_schedule: bool = False,
25
+ new_daily_budget: Optional[float] = None,
26
+ new_status: Optional[str] = "PAUSED"
27
+ ) -> str:
28
+ """
29
+ Duplicate a Meta Ads campaign with all its ad sets and ads.
30
+
31
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
32
+
33
+ Args:
34
+ campaign_id: Meta Ads campaign ID to duplicate
35
+ name_suffix: Suffix to add to the duplicated campaign name
36
+ include_ad_sets: Whether to duplicate ad sets within the campaign
37
+ include_ads: Whether to duplicate ads within ad sets
38
+ include_creatives: Whether to duplicate ad creatives
39
+ copy_schedule: Whether to copy the campaign schedule
40
+ new_daily_budget: Override the daily budget for the new campaign
41
+ new_status: Status for the new campaign (ACTIVE or PAUSED)
42
+ """
43
+ return await _forward_duplication_request(
44
+ "campaign",
45
+ campaign_id,
46
+ access_token,
47
+ {
48
+ "name_suffix": name_suffix,
49
+ "include_ad_sets": include_ad_sets,
50
+ "include_ads": include_ads,
51
+ "include_creatives": include_creatives,
52
+ "copy_schedule": copy_schedule,
53
+ "new_daily_budget": new_daily_budget,
54
+ "new_status": new_status
55
+ }
56
+ )
57
+
58
+ @mcp_server.tool()
59
+ @meta_api_tool
60
+ async def duplicate_adset(
61
+ adset_id: str,
62
+ access_token: str = None,
63
+ target_campaign_id: Optional[str] = None,
64
+ name_suffix: Optional[str] = " - Copy",
65
+ include_ads: bool = True,
66
+ include_creatives: bool = True,
67
+ new_daily_budget: Optional[float] = None,
68
+ new_targeting: Optional[Dict[str, Any]] = None,
69
+ new_status: Optional[str] = "PAUSED"
70
+ ) -> str:
71
+ """
72
+ Duplicate a Meta Ads ad set with its ads.
73
+
74
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
75
+
76
+ Args:
77
+ adset_id: Meta Ads ad set ID to duplicate
78
+ target_campaign_id: Campaign ID to move the duplicated ad set to (optional)
79
+ name_suffix: Suffix to add to the duplicated ad set name
80
+ include_ads: Whether to duplicate ads within the ad set
81
+ include_creatives: Whether to duplicate ad creatives
82
+ new_daily_budget: Override the daily budget for the new ad set
83
+ new_targeting: Override targeting settings for the new ad set
84
+ new_status: Status for the new ad set (ACTIVE or PAUSED)
85
+ """
86
+ return await _forward_duplication_request(
87
+ "adset",
88
+ adset_id,
89
+ access_token,
90
+ {
91
+ "target_campaign_id": target_campaign_id,
92
+ "name_suffix": name_suffix,
93
+ "include_ads": include_ads,
94
+ "include_creatives": include_creatives,
95
+ "new_daily_budget": new_daily_budget,
96
+ "new_targeting": new_targeting,
97
+ "new_status": new_status
98
+ }
99
+ )
100
+
101
+ @mcp_server.tool()
102
+ @meta_api_tool
103
+ async def duplicate_ad(
104
+ ad_id: str,
105
+ access_token: str = None,
106
+ target_adset_id: Optional[str] = None,
107
+ name_suffix: Optional[str] = " - Copy",
108
+ duplicate_creative: bool = True,
109
+ new_creative_name: Optional[str] = None,
110
+ new_status: Optional[str] = "PAUSED"
111
+ ) -> str:
112
+ """
113
+ Duplicate a Meta Ads ad.
114
+
115
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
116
+
117
+ Args:
118
+ ad_id: Meta Ads ad ID to duplicate
119
+ target_adset_id: Ad set ID to move the duplicated ad to (optional)
120
+ name_suffix: Suffix to add to the duplicated ad name
121
+ duplicate_creative: Whether to duplicate the ad creative
122
+ new_creative_name: Override name for the duplicated creative
123
+ new_status: Status for the new ad (ACTIVE or PAUSED)
124
+ """
125
+ return await _forward_duplication_request(
126
+ "ad",
127
+ ad_id,
128
+ access_token,
129
+ {
130
+ "target_adset_id": target_adset_id,
131
+ "name_suffix": name_suffix,
132
+ "duplicate_creative": duplicate_creative,
133
+ "new_creative_name": new_creative_name,
134
+ "new_status": new_status
135
+ }
136
+ )
137
+
138
+ @mcp_server.tool()
139
+ @meta_api_tool
140
+ async def duplicate_creative(
141
+ creative_id: str,
142
+ access_token: str = None,
143
+ name_suffix: Optional[str] = " - Copy",
144
+ new_primary_text: Optional[str] = None,
145
+ new_headline: Optional[str] = None,
146
+ new_description: Optional[str] = None,
147
+ new_cta_type: Optional[str] = None,
148
+ new_destination_url: Optional[str] = None
149
+ ) -> str:
150
+ """
151
+ Duplicate a Meta Ads creative.
152
+
153
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
154
+
155
+ Args:
156
+ creative_id: Meta Ads creative ID to duplicate
157
+ name_suffix: Suffix to add to the duplicated creative name
158
+ new_primary_text: Override the primary text for the new creative
159
+ new_headline: Override the headline for the new creative
160
+ new_description: Override the description for the new creative
161
+ new_cta_type: Override the call-to-action type for the new creative
162
+ new_destination_url: Override the destination URL for the new creative
163
+ """
164
+ return await _forward_duplication_request(
165
+ "creative",
166
+ creative_id,
167
+ access_token,
168
+ {
169
+ "name_suffix": name_suffix,
170
+ "new_primary_text": new_primary_text,
171
+ "new_headline": new_headline,
172
+ "new_description": new_description,
173
+ "new_cta_type": new_cta_type,
174
+ "new_destination_url": new_destination_url
175
+ }
176
+ )
177
+
178
+
179
+ async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
180
+ """
181
+ Forward duplication request to the cloud-hosted MCP API.
182
+
183
+ Args:
184
+ resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
185
+ resource_id: ID of the resource to duplicate
186
+ access_token: Meta API access token from the request
187
+ options: Duplication options
188
+ """
189
+ try:
190
+ if not access_token:
191
+ return json.dumps({
192
+ "error": "authentication_required",
193
+ "message": "Meta Ads access token not found",
194
+ "details": {
195
+ "required": "Valid access token from authenticated session"
196
+ }
197
+ }, indent=2)
198
+
199
+ # Construct the API endpoint
200
+ base_url = "https://mcp.pipeboard.co"
201
+ endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
202
+
203
+ # Prepare the request
204
+ headers = {
205
+ "Authorization": f"Bearer {access_token}",
206
+ "Content-Type": "application/json",
207
+ "User-Agent": "meta-ads-mcp/1.0"
208
+ }
209
+
210
+ # Remove None values from options
211
+ clean_options = {k: v for k, v in options.items() if v is not None}
212
+
213
+ # Make the request to the cloud service
214
+ async with httpx.AsyncClient(timeout=30.0) as client:
215
+ response = await client.post(
216
+ endpoint,
217
+ headers=headers,
218
+ json=clean_options
219
+ )
220
+
221
+ if response.status_code == 200:
222
+ result = response.json()
223
+ return json.dumps(result, indent=2)
224
+ elif response.status_code == 400:
225
+ # Validation failed
226
+ try:
227
+ error_data = response.json()
228
+ return json.dumps({
229
+ "success": False,
230
+ "error": "validation_failed",
231
+ "errors": error_data.get("errors", [response.text]),
232
+ "warnings": error_data.get("warnings", [])
233
+ }, indent=2)
234
+ except:
235
+ return json.dumps({
236
+ "success": False,
237
+ "error": "validation_failed",
238
+ "errors": [response.text],
239
+ "warnings": []
240
+ }, indent=2)
241
+ elif response.status_code == 401:
242
+ return json.dumps({
243
+ "success": False,
244
+ "error": "authentication_error",
245
+ "message": "Invalid or expired API token"
246
+ }, indent=2)
247
+ elif response.status_code == 402:
248
+ try:
249
+ error_data = response.json()
250
+ return json.dumps({
251
+ "success": False,
252
+ "error": "subscription_required",
253
+ "message": error_data.get("message", "This feature is not available in your current plan"),
254
+ "upgrade_url": error_data.get("upgrade_url", "https://pipeboard.co/upgrade"),
255
+ "suggestion": error_data.get("suggestion", "Please upgrade your account to access this feature")
256
+ }, indent=2)
257
+ except:
258
+ return json.dumps({
259
+ "success": False,
260
+ "error": "subscription_required",
261
+ "message": "This feature is not available in your current plan",
262
+ "upgrade_url": "https://pipeboard.co/upgrade",
263
+ "suggestion": "Please upgrade your account to access this feature"
264
+ }, indent=2)
265
+ elif response.status_code == 403:
266
+ try:
267
+ error_data = response.json()
268
+ # Check if this is a premium feature error
269
+ if error_data.get("error") == "premium_feature":
270
+ return json.dumps({
271
+ "success": False,
272
+ "error": "premium_feature_required",
273
+ "message": error_data.get("message", "This is a premium feature that requires subscription"),
274
+ "details": error_data.get("details", {
275
+ "upgrade_url": "https://pipeboard.co/upgrade",
276
+ "suggestion": "Please upgrade your account to access this feature"
277
+ })
278
+ }, indent=2)
279
+ else:
280
+ # Default to facebook connection required
281
+ return json.dumps({
282
+ "success": False,
283
+ "error": "facebook_connection_required",
284
+ "message": error_data.get("message", "You need to connect your Facebook account first"),
285
+ "details": error_data.get("details", {
286
+ "login_flow_url": "/connections",
287
+ "auth_flow_url": "/api/meta/auth"
288
+ })
289
+ }, indent=2)
290
+ except:
291
+ return json.dumps({
292
+ "success": False,
293
+ "error": "facebook_connection_required",
294
+ "message": "You need to connect your Facebook account first",
295
+ "details": {
296
+ "login_flow_url": "/connections",
297
+ "auth_flow_url": "/api/meta/auth"
298
+ }
299
+ }, indent=2)
300
+ elif response.status_code == 404:
301
+ return json.dumps({
302
+ "success": False,
303
+ "error": "resource_not_found",
304
+ "message": f"{resource_type.title()} not found or access denied",
305
+ "suggestion": f"Verify the {resource_type} ID and your Facebook account permissions"
306
+ }, indent=2)
307
+ elif response.status_code == 429:
308
+ return json.dumps({
309
+ "error": "rate_limit_exceeded",
310
+ "message": "Meta API rate limit exceeded",
311
+ "details": {
312
+ "suggestion": "Please wait before retrying",
313
+ "retry_after": response.headers.get("Retry-After", "60")
314
+ }
315
+ }, indent=2)
316
+ elif response.status_code == 502:
317
+ try:
318
+ error_data = response.json()
319
+ return json.dumps({
320
+ "success": False,
321
+ "error": "meta_api_error",
322
+ "message": error_data.get("message", "Facebook API error"),
323
+ "recoverable": True,
324
+ "suggestion": "Please wait 5 minutes before retrying"
325
+ }, indent=2)
326
+ except:
327
+ return json.dumps({
328
+ "success": False,
329
+ "error": "meta_api_error",
330
+ "message": "Facebook API error",
331
+ "recoverable": True,
332
+ "suggestion": "Please wait 5 minutes before retrying"
333
+ }, indent=2)
334
+ else:
335
+ error_detail = response.text
336
+ try:
337
+ error_json = response.json()
338
+ error_detail = error_json.get("message", error_detail)
339
+ except:
340
+ pass
341
+
342
+ return json.dumps({
343
+ "error": "duplication_failed",
344
+ "message": f"Failed to duplicate {resource_type}",
345
+ "details": {
346
+ "status_code": response.status_code,
347
+ "error_detail": error_detail,
348
+ "resource_type": resource_type,
349
+ "resource_id": resource_id
350
+ }
351
+ }, indent=2)
352
+
353
+ except httpx.TimeoutException:
354
+ return json.dumps({
355
+ "error": "request_timeout",
356
+ "message": "Request to duplication service timed out",
357
+ "details": {
358
+ "suggestion": "Please try again later",
359
+ "timeout": "30 seconds"
360
+ }
361
+ }, indent=2)
362
+
363
+ except httpx.RequestError as e:
364
+ return json.dumps({
365
+ "error": "network_error",
366
+ "message": "Failed to connect to duplication service",
367
+ "details": {
368
+ "error": str(e),
369
+ "suggestion": "Check your internet connection and try again"
370
+ }
371
+ }, indent=2)
372
+
373
+ except Exception as e:
374
+ return json.dumps({
375
+ "error": "unexpected_error",
376
+ "message": f"Unexpected error during {resource_type} duplication",
377
+ "details": {
378
+ "error": str(e),
379
+ "resource_type": resource_type,
380
+ "resource_id": resource_id
381
+ }
382
+ }, indent=2)
383
+
384
+
385
+ def _get_estimated_components(resource_type: str, options: Dict[str, Any]) -> Dict[str, Any]:
386
+ """Get estimated components that would be duplicated."""
387
+ if resource_type == "campaign":
388
+ components = {"campaigns": 1}
389
+ if options.get("include_ad_sets", True):
390
+ components["ad_sets"] = "3-5 (estimated)"
391
+ if options.get("include_ads", True):
392
+ components["ads"] = "5-15 (estimated)"
393
+ if options.get("include_creatives", True):
394
+ components["creatives"] = "5-15 (estimated)"
395
+ return components
396
+ elif resource_type == "adset":
397
+ components = {"ad_sets": 1}
398
+ if options.get("include_ads", True):
399
+ components["ads"] = "2-5 (estimated)"
400
+ if options.get("include_creatives", True):
401
+ components["creatives"] = "2-5 (estimated)"
402
+ return components
403
+ elif resource_type == "ad":
404
+ components = {"ads": 1}
405
+ if options.get("duplicate_creative", True):
406
+ components["creatives"] = 1
407
+ return components
408
+ elif resource_type == "creative":
409
+ return {"creatives": 1}
410
+
411
+ return {}
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.4.0"
8
- description = "Model Calling Protocol (MCP) plugin for interacting with Meta Ads API"
7
+ version = "0.4.1"
8
+ description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  authors = [
12
12
  {name = "Yves Junqueira", email = "yves.junqueira@gmail.com"},
13
13
  ]
14
14
  keywords = ["meta", "facebook", "ads", "api", "mcp", "claude"]
15
- license = {text = "MIT"}
15
+ license = {text = "Apache-2.0"}
16
16
  classifiers = [
17
17
  "Programming Language :: Python :: 3",
18
- "License :: OSI Approved :: MIT License",
18
+ "License :: OSI Approved :: Apache Software License",
19
19
  "Operating System :: OS Independent",
20
20
  ]
21
21
  dependencies = [
@@ -26,6 +26,8 @@ dependencies = [
26
26
  "Pillow>=10.0.0",
27
27
  "pathlib>=1.0.1",
28
28
  "python-dateutil>=2.8.2",
29
+ "pytest>=8.4.1",
30
+ "pytest-asyncio>=1.0.0",
29
31
  ]
30
32
 
31
33
  [project.urls]