meta-ads-mcp 0.3.10__py3-none-any.whl → 0.4.1__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 +1 -3
- meta_ads_mcp/core/__init__.py +2 -2
- meta_ads_mcp/core/adsets.py +15 -6
- meta_ads_mcp/core/auth.py +29 -17
- meta_ads_mcp/core/authentication.py +17 -8
- meta_ads_mcp/core/callback_server.py +9 -0
- meta_ads_mcp/core/duplication.py +411 -0
- meta_ads_mcp/core/insights.py +2 -369
- {meta_ads_mcp-0.3.10.dist-info → meta_ads_mcp-0.4.1.dist-info}/METADATA +11 -20
- {meta_ads_mcp-0.3.10.dist-info → meta_ads_mcp-0.4.1.dist-info}/RECORD +13 -12
- {meta_ads_mcp-0.3.10.dist-info → meta_ads_mcp-0.4.1.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.3.10.dist-info → meta_ads_mcp-0.4.1.dist-info}/entry_points.txt +0 -0
- {meta_ads_mcp-0.3.10.dist-info → meta_ads_mcp-0.4.1.dist-info}/licenses/LICENSE +0 -0
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.
|
|
10
|
+
__version__ = "0.4.1"
|
|
11
11
|
|
|
12
12
|
__all__ = [
|
|
13
13
|
'get_ad_accounts',
|
|
@@ -24,7 +24,6 @@ __all__ = [
|
|
|
24
24
|
'get_ad_image',
|
|
25
25
|
'update_ad',
|
|
26
26
|
'get_insights',
|
|
27
|
-
'debug_image_download',
|
|
28
27
|
'get_login_link',
|
|
29
28
|
'login_cli',
|
|
30
29
|
'main'
|
|
@@ -46,7 +45,6 @@ from .core import (
|
|
|
46
45
|
get_ad_image,
|
|
47
46
|
update_ad,
|
|
48
47
|
get_insights,
|
|
49
|
-
debug_image_download,
|
|
50
48
|
get_login_link,
|
|
51
49
|
login_cli,
|
|
52
50
|
main
|
meta_ads_mcp/core/__init__.py
CHANGED
|
@@ -5,13 +5,14 @@ from .accounts import get_ad_accounts, get_account_info
|
|
|
5
5
|
from .campaigns import get_campaigns, get_campaign_details, create_campaign
|
|
6
6
|
from .adsets import get_adsets, get_adset_details, update_adset
|
|
7
7
|
from .ads import get_ads, get_ad_details, get_ad_creatives, get_ad_image, update_ad
|
|
8
|
-
from .insights import get_insights
|
|
8
|
+
from .insights import get_insights
|
|
9
9
|
from .authentication import get_login_link
|
|
10
10
|
from .server import login_cli, main
|
|
11
11
|
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',
|
|
@@ -29,7 +30,6 @@ __all__ = [
|
|
|
29
30
|
'get_ad_image',
|
|
30
31
|
'update_ad',
|
|
31
32
|
'get_insights',
|
|
32
|
-
'debug_image_download',
|
|
33
33
|
'get_login_link',
|
|
34
34
|
'login_cli',
|
|
35
35
|
'login',
|
meta_ads_mcp/core/adsets.py
CHANGED
|
@@ -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()
|
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
|
-
|
|
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 {}
|
meta_ads_mcp/core/insights.py
CHANGED
|
@@ -56,374 +56,7 @@ async def get_insights(access_token: str = None, object_id: str = None,
|
|
|
56
56
|
return json.dumps(data, indent=2)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
@mcp_server.tool()
|
|
60
|
-
@meta_api_tool
|
|
61
|
-
async def debug_image_download(access_token: str = None, url: str = "", ad_id: str = "") -> str:
|
|
62
|
-
"""
|
|
63
|
-
Debug image download issues and report detailed diagnostics.
|
|
64
|
-
|
|
65
|
-
Args:
|
|
66
|
-
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
67
|
-
url: Direct image URL to test (optional)
|
|
68
|
-
ad_id: Meta Ads ad ID (optional, used if url is not provided)
|
|
69
|
-
"""
|
|
70
|
-
results = {
|
|
71
|
-
"diagnostics": {
|
|
72
|
-
"timestamp": str(datetime.datetime.now()),
|
|
73
|
-
"methods_tried": [],
|
|
74
|
-
"request_details": [],
|
|
75
|
-
"network_info": {}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
# If no URL provided but ad_id is, get URL from ad creative
|
|
80
|
-
if not url and ad_id:
|
|
81
|
-
print(f"Getting image URL from ad creative for ad {ad_id}")
|
|
82
|
-
# Get the creative details
|
|
83
|
-
from .ads import get_ad_creatives
|
|
84
|
-
creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token)
|
|
85
|
-
creative_data = json.loads(creative_json)
|
|
86
|
-
results["creative_data"] = creative_data
|
|
87
|
-
|
|
88
|
-
# Look for image URL in the creative
|
|
89
|
-
if "full_image_url" in creative_data:
|
|
90
|
-
url = creative_data.get("full_image_url")
|
|
91
|
-
elif "thumbnail_url" in creative_data:
|
|
92
|
-
url = creative_data.get("thumbnail_url")
|
|
93
|
-
|
|
94
|
-
if not url:
|
|
95
|
-
return json.dumps({
|
|
96
|
-
"error": "No image URL provided or found in ad creative",
|
|
97
|
-
"results": results
|
|
98
|
-
}, indent=2)
|
|
99
|
-
|
|
100
|
-
results["image_url"] = url
|
|
101
|
-
|
|
102
|
-
# Try to get network information to help debug
|
|
103
|
-
try:
|
|
104
|
-
import socket
|
|
105
|
-
from urllib.parse import urlparse
|
|
106
|
-
hostname = urlparse(url).netloc
|
|
107
|
-
ip_address = socket.gethostbyname(hostname)
|
|
108
|
-
results["diagnostics"]["network_info"] = {
|
|
109
|
-
"hostname": hostname,
|
|
110
|
-
"ip_address": ip_address,
|
|
111
|
-
"is_facebook_cdn": "fbcdn" in hostname
|
|
112
|
-
}
|
|
113
|
-
except Exception as e:
|
|
114
|
-
results["diagnostics"]["network_info"] = {
|
|
115
|
-
"error": str(e)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
# Method 1: Basic download
|
|
119
|
-
method_result = {
|
|
120
|
-
"method": "Basic download with standard headers",
|
|
121
|
-
"success": False
|
|
122
|
-
}
|
|
123
|
-
results["diagnostics"]["methods_tried"].append(method_result)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
headers = {
|
|
127
|
-
"User-Agent": "curl/8.4.0"
|
|
128
|
-
}
|
|
129
|
-
import httpx
|
|
130
|
-
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
131
|
-
response = await client.get(url, headers=headers, timeout=30.0)
|
|
132
|
-
method_result["status_code"] = response.status_code
|
|
133
|
-
method_result["headers"] = dict(response.headers)
|
|
134
|
-
|
|
135
|
-
if response.status_code == 200:
|
|
136
|
-
method_result["success"] = True
|
|
137
|
-
method_result["content_length"] = len(response.content)
|
|
138
|
-
method_result["content_type"] = response.headers.get("content-type")
|
|
139
|
-
|
|
140
|
-
# Save this successful result
|
|
141
|
-
results["image_data"] = {
|
|
142
|
-
"length": len(response.content),
|
|
143
|
-
"type": response.headers.get("content-type"),
|
|
144
|
-
"base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
|
|
145
|
-
}
|
|
146
|
-
except Exception as e:
|
|
147
|
-
method_result["error"] = str(e)
|
|
148
|
-
|
|
149
|
-
# Method 2: Browser emulation
|
|
150
|
-
method_result = {
|
|
151
|
-
"method": "Browser emulation with cookies",
|
|
152
|
-
"success": False
|
|
153
|
-
}
|
|
154
|
-
results["diagnostics"]["methods_tried"].append(method_result)
|
|
155
|
-
|
|
156
|
-
try:
|
|
157
|
-
headers = {
|
|
158
|
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
|
159
|
-
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
|
160
|
-
"Accept-Language": "en-US,en;q=0.9",
|
|
161
|
-
"Referer": "https://www.facebook.com/",
|
|
162
|
-
"Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC"
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
import httpx
|
|
166
|
-
async with httpx.AsyncClient(follow_redirects=True) as client:
|
|
167
|
-
response = await client.get(url, headers=headers, timeout=30.0)
|
|
168
|
-
method_result["status_code"] = response.status_code
|
|
169
|
-
method_result["headers"] = dict(response.headers)
|
|
170
|
-
|
|
171
|
-
if response.status_code == 200:
|
|
172
|
-
method_result["success"] = True
|
|
173
|
-
method_result["content_length"] = len(response.content)
|
|
174
|
-
method_result["content_type"] = response.headers.get("content-type")
|
|
175
|
-
|
|
176
|
-
# If first method didn't succeed, save this successful result
|
|
177
|
-
if "image_data" not in results:
|
|
178
|
-
results["image_data"] = {
|
|
179
|
-
"length": len(response.content),
|
|
180
|
-
"type": response.headers.get("content-type"),
|
|
181
|
-
"base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
|
|
182
|
-
}
|
|
183
|
-
except Exception as e:
|
|
184
|
-
method_result["error"] = str(e)
|
|
185
|
-
|
|
186
|
-
# Method 3: Graph API direct access (if applicable)
|
|
187
|
-
if "fbcdn" in url or "facebook" in url:
|
|
188
|
-
method_result = {
|
|
189
|
-
"method": "Graph API direct access",
|
|
190
|
-
"success": False
|
|
191
|
-
}
|
|
192
|
-
results["diagnostics"]["methods_tried"].append(method_result)
|
|
193
|
-
|
|
194
|
-
try:
|
|
195
|
-
# Try to reconstruct the attachment ID from URL if possible
|
|
196
|
-
from urllib.parse import urlparse
|
|
197
|
-
url_parts = urlparse(url).path.split("/")
|
|
198
|
-
potential_ids = [part for part in url_parts if part.isdigit() and len(part) > 10]
|
|
199
|
-
|
|
200
|
-
if potential_ids:
|
|
201
|
-
attachment_id = potential_ids[0]
|
|
202
|
-
endpoint = f"{attachment_id}?fields=url,width,height"
|
|
203
|
-
api_result = await make_api_request(endpoint, access_token)
|
|
204
|
-
|
|
205
|
-
method_result["api_response"] = api_result
|
|
206
|
-
|
|
207
|
-
if "url" in api_result:
|
|
208
|
-
graph_url = api_result["url"]
|
|
209
|
-
method_result["graph_url"] = graph_url
|
|
210
|
-
|
|
211
|
-
# Try to download from this Graph API URL
|
|
212
|
-
import httpx
|
|
213
|
-
async with httpx.AsyncClient() as client:
|
|
214
|
-
response = await client.get(graph_url, timeout=30.0)
|
|
215
|
-
|
|
216
|
-
method_result["status_code"] = response.status_code
|
|
217
|
-
if response.status_code == 200:
|
|
218
|
-
method_result["success"] = True
|
|
219
|
-
method_result["content_length"] = len(response.content)
|
|
220
|
-
|
|
221
|
-
# If previous methods didn't succeed, save this successful result
|
|
222
|
-
if "image_data" not in results:
|
|
223
|
-
results["image_data"] = {
|
|
224
|
-
"length": len(response.content),
|
|
225
|
-
"type": response.headers.get("content-type"),
|
|
226
|
-
"base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
|
|
227
|
-
}
|
|
228
|
-
except Exception as e:
|
|
229
|
-
method_result["error"] = str(e)
|
|
230
|
-
|
|
231
|
-
# Generate a recommendation based on what we found
|
|
232
|
-
if "image_data" in results:
|
|
233
|
-
results["recommendation"] = "At least one download method succeeded. Consider implementing the successful method in the main code."
|
|
234
|
-
else:
|
|
235
|
-
# Check if the error appears to be access-related
|
|
236
|
-
access_errors = False
|
|
237
|
-
for method in results["diagnostics"]["methods_tried"]:
|
|
238
|
-
if method.get("status_code") in [401, 403, 503]:
|
|
239
|
-
access_errors = True
|
|
240
|
-
|
|
241
|
-
if access_errors:
|
|
242
|
-
results["recommendation"] = "Authentication or authorization errors detected. Images may require direct Facebook authentication not possible via API."
|
|
243
|
-
else:
|
|
244
|
-
results["recommendation"] = "Network or other technical errors detected. Check URL expiration or CDN restrictions."
|
|
245
|
-
|
|
246
|
-
return json.dumps(results, indent=2)
|
|
247
59
|
|
|
248
60
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
async def save_ad_image_via_api(access_token: str = None, ad_id: str = None) -> str:
|
|
252
|
-
"""
|
|
253
|
-
Try to save an ad image by using the Marketing API's attachment endpoints.
|
|
254
|
-
This is an alternative approach when direct image download fails.
|
|
255
|
-
|
|
256
|
-
Args:
|
|
257
|
-
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
258
|
-
ad_id: Meta Ads ad ID
|
|
259
|
-
"""
|
|
260
|
-
if not ad_id:
|
|
261
|
-
return json.dumps({"error": "No ad ID provided"}, indent=2)
|
|
262
|
-
|
|
263
|
-
# First get the ad's creative ID
|
|
264
|
-
endpoint = f"{ad_id}"
|
|
265
|
-
params = {
|
|
266
|
-
"fields": "creative,account_id"
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
ad_data = await make_api_request(endpoint, access_token, params)
|
|
270
|
-
|
|
271
|
-
if "error" in ad_data:
|
|
272
|
-
return json.dumps({
|
|
273
|
-
"error": "Could not get ad data",
|
|
274
|
-
"details": ad_data
|
|
275
|
-
}, indent=2)
|
|
276
|
-
|
|
277
|
-
if "creative" not in ad_data or "id" not in ad_data["creative"]:
|
|
278
|
-
return json.dumps({
|
|
279
|
-
"error": "No creative ID found for this ad",
|
|
280
|
-
"ad_data": ad_data
|
|
281
|
-
}, indent=2)
|
|
282
|
-
|
|
283
|
-
creative_id = ad_data["creative"]["id"]
|
|
284
|
-
account_id = ad_data.get("account_id", "")
|
|
285
|
-
|
|
286
|
-
# Now get the creative object
|
|
287
|
-
creative_endpoint = f"{creative_id}"
|
|
288
|
-
creative_params = {
|
|
289
|
-
"fields": "id,name,thumbnail_url,image_hash,asset_feed_spec"
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
creative_data = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
293
|
-
|
|
294
|
-
if "error" in creative_data:
|
|
295
|
-
return json.dumps({
|
|
296
|
-
"error": "Could not get creative data",
|
|
297
|
-
"details": creative_data
|
|
298
|
-
}, indent=2)
|
|
299
|
-
|
|
300
|
-
# Approach 1: Try to get image through adimages endpoint if we have image_hash
|
|
301
|
-
image_hash = None
|
|
302
|
-
if "image_hash" in creative_data:
|
|
303
|
-
image_hash = creative_data["image_hash"]
|
|
304
|
-
elif "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"] and len(creative_data["asset_feed_spec"]["images"]) > 0:
|
|
305
|
-
image_hash = creative_data["asset_feed_spec"]["images"][0].get("hash")
|
|
306
|
-
|
|
307
|
-
result = {
|
|
308
|
-
"ad_id": ad_id,
|
|
309
|
-
"creative_id": creative_id,
|
|
310
|
-
"attempts": []
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if image_hash and account_id:
|
|
314
|
-
attempt = {
|
|
315
|
-
"method": "adimages endpoint with hash",
|
|
316
|
-
"success": False
|
|
317
|
-
}
|
|
318
|
-
result["attempts"].append(attempt)
|
|
319
|
-
|
|
320
|
-
try:
|
|
321
|
-
image_endpoint = f"act_{account_id}/adimages"
|
|
322
|
-
image_params = {
|
|
323
|
-
"hashes": [image_hash]
|
|
324
|
-
}
|
|
325
|
-
image_data = await make_api_request(image_endpoint, access_token, image_params)
|
|
326
|
-
attempt["response"] = image_data
|
|
327
|
-
|
|
328
|
-
if "data" in image_data and len(image_data["data"]) > 0 and "url" in image_data["data"][0]:
|
|
329
|
-
url = image_data["data"][0]["url"]
|
|
330
|
-
attempt["url"] = url
|
|
331
|
-
|
|
332
|
-
# Try to download the image
|
|
333
|
-
image_bytes = await download_image(url)
|
|
334
|
-
if image_bytes:
|
|
335
|
-
attempt["success"] = True
|
|
336
|
-
attempt["image_size"] = len(image_bytes)
|
|
337
|
-
|
|
338
|
-
# Save the image
|
|
339
|
-
resource_id = f"ad_creative_{ad_id}_method1"
|
|
340
|
-
resource_info = create_resource_from_image(
|
|
341
|
-
image_bytes,
|
|
342
|
-
resource_id,
|
|
343
|
-
f"Ad Creative for {ad_id} (Method 1)"
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
# Return success with resource info
|
|
347
|
-
result.update(resource_info)
|
|
348
|
-
result["success"] = True
|
|
349
|
-
base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
|
|
350
|
-
result["base64_sample"] = base64_sample
|
|
351
|
-
except Exception as e:
|
|
352
|
-
attempt["error"] = str(e)
|
|
353
|
-
|
|
354
|
-
# Approach 2: Try directly with the thumbnails endpoint
|
|
355
|
-
attempt = {
|
|
356
|
-
"method": "thumbnails endpoint on creative",
|
|
357
|
-
"success": False
|
|
358
|
-
}
|
|
359
|
-
result["attempts"].append(attempt)
|
|
360
|
-
|
|
361
|
-
try:
|
|
362
|
-
thumbnails_endpoint = f"{creative_id}/thumbnails"
|
|
363
|
-
thumbnails_params = {}
|
|
364
|
-
thumbnails_data = await make_api_request(thumbnails_endpoint, access_token, thumbnails_params)
|
|
365
|
-
attempt["response"] = thumbnails_data
|
|
366
|
-
|
|
367
|
-
if "data" in thumbnails_data and len(thumbnails_data["data"]) > 0:
|
|
368
|
-
for thumbnail in thumbnails_data["data"]:
|
|
369
|
-
if "uri" in thumbnail:
|
|
370
|
-
url = thumbnail["uri"]
|
|
371
|
-
attempt["url"] = url
|
|
372
|
-
|
|
373
|
-
# Try to download the image
|
|
374
|
-
image_bytes = await download_image(url)
|
|
375
|
-
if image_bytes:
|
|
376
|
-
attempt["success"] = True
|
|
377
|
-
attempt["image_size"] = len(image_bytes)
|
|
378
|
-
|
|
379
|
-
# Save the image if method 1 didn't already succeed
|
|
380
|
-
if "success" not in result or not result["success"]:
|
|
381
|
-
resource_id = f"ad_creative_{ad_id}_method2"
|
|
382
|
-
resource_info = create_resource_from_image(
|
|
383
|
-
image_bytes,
|
|
384
|
-
resource_id,
|
|
385
|
-
f"Ad Creative for {ad_id} (Method 2)"
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
# Return success with resource info
|
|
389
|
-
result.update(resource_info)
|
|
390
|
-
result["success"] = True
|
|
391
|
-
base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
|
|
392
|
-
result["base64_sample"] = base64_sample
|
|
393
|
-
|
|
394
|
-
# No need to try more thumbnails if we succeeded
|
|
395
|
-
break
|
|
396
|
-
except Exception as e:
|
|
397
|
-
attempt["error"] = str(e)
|
|
398
|
-
|
|
399
|
-
# Approach 3: Try using the preview shareable link as an alternate source
|
|
400
|
-
attempt = {
|
|
401
|
-
"method": "preview_shareable_link",
|
|
402
|
-
"success": False
|
|
403
|
-
}
|
|
404
|
-
result["attempts"].append(attempt)
|
|
405
|
-
|
|
406
|
-
try:
|
|
407
|
-
# Get ad details with preview link
|
|
408
|
-
ad_preview_endpoint = f"{ad_id}"
|
|
409
|
-
ad_preview_params = {
|
|
410
|
-
"fields": "preview_shareable_link"
|
|
411
|
-
}
|
|
412
|
-
ad_preview_data = await make_api_request(ad_preview_endpoint, access_token, ad_preview_params)
|
|
413
|
-
|
|
414
|
-
if "preview_shareable_link" in ad_preview_data:
|
|
415
|
-
preview_link = ad_preview_data["preview_shareable_link"]
|
|
416
|
-
attempt["preview_link"] = preview_link
|
|
417
|
-
|
|
418
|
-
# We can't directly download the preview image, but let's note it for manual inspection
|
|
419
|
-
attempt["note"] = "Preview link available for manual inspection in browser"
|
|
420
|
-
except Exception as e:
|
|
421
|
-
attempt["error"] = str(e)
|
|
422
|
-
|
|
423
|
-
# Overall result
|
|
424
|
-
if "success" in result and result["success"]:
|
|
425
|
-
result["message"] = "Successfully retrieved ad image through one of the API methods"
|
|
426
|
-
else:
|
|
427
|
-
result["message"] = "Failed to retrieve ad image through any API method"
|
|
428
|
-
|
|
429
|
-
return json.dumps(result, indent=2)
|
|
61
|
+
|
|
62
|
+
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.
|
|
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
|
}
|
|
@@ -313,21 +312,13 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
313
312
|
- `level`: Level of aggregation (ad, adset, campaign, account)
|
|
314
313
|
- Returns: Performance metrics for the specified object
|
|
315
314
|
|
|
316
|
-
20. `
|
|
317
|
-
- Debug image download issues and report detailed diagnostics
|
|
318
|
-
- Inputs:
|
|
319
|
-
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
320
|
-
- `url`: Direct image URL to test (optional)
|
|
321
|
-
- `ad_id`: Meta Ads ad ID (optional, used if url is not provided)
|
|
322
|
-
- Returns: Diagnostic information about image download attempts
|
|
323
|
-
|
|
324
|
-
21. `mcp_meta_ads_get_login_link`
|
|
315
|
+
20. `mcp_meta_ads_get_login_link`
|
|
325
316
|
- Get a clickable login link for Meta Ads authentication
|
|
326
317
|
- Inputs:
|
|
327
318
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
328
319
|
- Returns: A clickable resource link for Meta authentication
|
|
329
320
|
|
|
330
|
-
|
|
321
|
+
21. `mcp_meta-ads_create_budget_schedule`
|
|
331
322
|
- Create a budget schedule for a Meta Ads campaign.
|
|
332
323
|
- Inputs:
|
|
333
324
|
- `campaign_id`: Meta Ads campaign ID.
|
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
meta_ads_mcp/__init__.py,sha256=
|
|
1
|
+
meta_ads_mcp/__init__.py,sha256=L28BjkKhMZJU54HlrDum5EOovTvkzo5jSvFDE_g8QJw,1182
|
|
2
2
|
meta_ads_mcp/__main__.py,sha256=XaQt3iXftG_7f0Zu7Wop9SeFgrD2WBn0EQOaPMc27d8,207
|
|
3
|
-
meta_ads_mcp/core/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
10
|
-
meta_ads_mcp/core/authentication.py,sha256=
|
|
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=
|
|
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/duplication.py,sha256=o9vYczBCiF7bnRZBUGjI2ib06z44E7e7kvJM44jr83k,17052
|
|
14
15
|
meta_ads_mcp/core/http_auth_integration.py,sha256=ZJHuxK1Kwtr9gvwfC5HZOLH5MW-HnDDKqJc4xuG5yVE,10060
|
|
15
|
-
meta_ads_mcp/core/insights.py,sha256=
|
|
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.
|
|
22
|
-
meta_ads_mcp-0.
|
|
23
|
-
meta_ads_mcp-0.
|
|
24
|
-
meta_ads_mcp-0.
|
|
25
|
-
meta_ads_mcp-0.
|
|
22
|
+
meta_ads_mcp-0.4.1.dist-info/METADATA,sha256=XFm8KEWGs0Ej4GQtppFW5kANqnO6URP2FkCJCUHxIH4,17239
|
|
23
|
+
meta_ads_mcp-0.4.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
24
|
+
meta_ads_mcp-0.4.1.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
|
|
25
|
+
meta_ads_mcp-0.4.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
26
|
+
meta_ads_mcp-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|