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.
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/PKG-INFO +9 -10
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/README.md +3 -6
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/__init__.py +1 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/adsets.py +15 -6
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/auth.py +29 -17
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/authentication.py +17 -8
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/callback_server.py +9 -0
- meta_ads_mcp-0.4.1/meta_ads_mcp/core/duplication.py +411 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/pyproject.toml +6 -4
- meta_ads_mcp-0.4.1/tests/README_REGRESSION_TESTS.md +185 -0
- meta_ads_mcp-0.4.1/tests/test_duplication.py +136 -0
- meta_ads_mcp-0.4.1/tests/test_duplication_regression.py +805 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/.gitignore +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/Dockerfile +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/LICENSE +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/RELEASE.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/examples/README.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/future_improvements.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/requirements.txt +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/setup.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/smithery.yaml +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/README.md +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.4.0 → meta_ads_mcp-0.4.1}/tests/test_http_transport.py +0 -0
- {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.
|
|
4
|
-
Summary: Model
|
|
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:
|
|
8
|
+
License: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Keywords: ads,api,claude,facebook,mcp,meta
|
|
11
|
-
Classifier: License :: OSI Approved ::
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
}
|
|
@@ -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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
8
|
-
description = "Model
|
|
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 = "
|
|
15
|
+
license = {text = "Apache-2.0"}
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
-
"License :: OSI Approved ::
|
|
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]
|