meta-ads-mcp 0.10.6__tar.gz → 0.10.9__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.10.6 → meta_ads_mcp-0.10.9}/PKG-INFO +32 -2
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/README.md +31 -1
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/__init__.py +2 -3
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/ads.py +49 -1
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/campaigns.py +8 -2
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/pyproject.toml +1 -1
- meta_ads_mcp-0.10.9/tests/test_upload_ad_image.py +134 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/.gitignore +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/Dockerfile +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/LICENSE +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/RELEASE.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/examples/README.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/future_improvements.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/requirements.txt +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/setup.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/smithery.yaml +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/README.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/e2e_account_info_search_issue.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_account_info_access_fix.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_dsa_beneficiary.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_dsa_integration.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_estimate_audience_size.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_estimate_audience_size_e2e.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_mobile_app_adset_creation.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_mobile_app_adset_issue.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.10.6 → meta_ads_mcp-0.10.9}/tests/test_update_ad_creative_id.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.9
|
|
4
4
|
Summary: Model Context Protocol (MCP) server 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
|
|
@@ -176,13 +176,43 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
176
176
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
177
177
|
- `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
178
178
|
- `name`: Campaign name
|
|
179
|
-
- `objective`: Campaign objective (
|
|
179
|
+
- `objective`: Campaign objective (ODAX, outcome-based). Must be one of:
|
|
180
|
+
- `OUTCOME_AWARENESS`
|
|
181
|
+
- `OUTCOME_TRAFFIC`
|
|
182
|
+
- `OUTCOME_ENGAGEMENT`
|
|
183
|
+
- `OUTCOME_LEADS`
|
|
184
|
+
- `OUTCOME_SALES`
|
|
185
|
+
- `OUTCOME_APP_PROMOTION`
|
|
186
|
+
|
|
187
|
+
Note: Legacy objectives such as `BRAND_AWARENESS`, `LINK_CLICKS`, `CONVERSIONS`, `APP_INSTALLS`, etc. are no longer valid for new campaigns and will cause a 400 error. Use the outcome-based values above. Common mappings:
|
|
188
|
+
- `BRAND_AWARENESS` → `OUTCOME_AWARENESS`
|
|
189
|
+
- `REACH` → `OUTCOME_AWARENESS`
|
|
190
|
+
- `LINK_CLICKS`, `TRAFFIC` → `OUTCOME_TRAFFIC`
|
|
191
|
+
- `POST_ENGAGEMENT`, `PAGE_LIKES`, `EVENT_RESPONSES`, `VIDEO_VIEWS` → `OUTCOME_ENGAGEMENT`
|
|
192
|
+
- `LEAD_GENERATION` → `OUTCOME_LEADS`
|
|
193
|
+
- `CONVERSIONS`, `CATALOG_SALES`, `MESSAGES` (sales-focused flows) → `OUTCOME_SALES`
|
|
194
|
+
- `APP_INSTALLS` → `OUTCOME_APP_PROMOTION`
|
|
180
195
|
- `status`: Initial campaign status (default: PAUSED)
|
|
181
196
|
- `special_ad_categories`: List of special ad categories if applicable
|
|
182
197
|
- `daily_budget`: Daily budget in account currency (in cents)
|
|
183
198
|
- `lifetime_budget`: Lifetime budget in account currency (in cents)
|
|
199
|
+
- `bid_strategy`: Bid strategy. Must be one of: `LOWEST_COST_WITHOUT_CAP`, `LOWEST_COST_WITH_BID_CAP`, `COST_CAP`, `LOWEST_COST_WITH_MIN_ROAS`.
|
|
184
200
|
- Returns: Confirmation with new campaign details
|
|
185
201
|
|
|
202
|
+
- Example:
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"name": "2025 - Bedroom Furniture - Awareness",
|
|
206
|
+
"account_id": "act_123456789012345",
|
|
207
|
+
"objective": "OUTCOME_AWARENESS",
|
|
208
|
+
"special_ad_categories": [],
|
|
209
|
+
"status": "PAUSED",
|
|
210
|
+
"buying_type": "AUCTION",
|
|
211
|
+
"bid_strategy": "LOWEST_COST_WITHOUT_CAP",
|
|
212
|
+
"daily_budget": 10000
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
186
216
|
7. `mcp_meta_ads_get_adsets`
|
|
187
217
|
- Get ad sets for a Meta Ads account with optional filtering by campaign
|
|
188
218
|
- Inputs:
|
|
@@ -151,13 +151,43 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
151
151
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
152
152
|
- `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
153
153
|
- `name`: Campaign name
|
|
154
|
-
- `objective`: Campaign objective (
|
|
154
|
+
- `objective`: Campaign objective (ODAX, outcome-based). Must be one of:
|
|
155
|
+
- `OUTCOME_AWARENESS`
|
|
156
|
+
- `OUTCOME_TRAFFIC`
|
|
157
|
+
- `OUTCOME_ENGAGEMENT`
|
|
158
|
+
- `OUTCOME_LEADS`
|
|
159
|
+
- `OUTCOME_SALES`
|
|
160
|
+
- `OUTCOME_APP_PROMOTION`
|
|
161
|
+
|
|
162
|
+
Note: Legacy objectives such as `BRAND_AWARENESS`, `LINK_CLICKS`, `CONVERSIONS`, `APP_INSTALLS`, etc. are no longer valid for new campaigns and will cause a 400 error. Use the outcome-based values above. Common mappings:
|
|
163
|
+
- `BRAND_AWARENESS` → `OUTCOME_AWARENESS`
|
|
164
|
+
- `REACH` → `OUTCOME_AWARENESS`
|
|
165
|
+
- `LINK_CLICKS`, `TRAFFIC` → `OUTCOME_TRAFFIC`
|
|
166
|
+
- `POST_ENGAGEMENT`, `PAGE_LIKES`, `EVENT_RESPONSES`, `VIDEO_VIEWS` → `OUTCOME_ENGAGEMENT`
|
|
167
|
+
- `LEAD_GENERATION` → `OUTCOME_LEADS`
|
|
168
|
+
- `CONVERSIONS`, `CATALOG_SALES`, `MESSAGES` (sales-focused flows) → `OUTCOME_SALES`
|
|
169
|
+
- `APP_INSTALLS` → `OUTCOME_APP_PROMOTION`
|
|
155
170
|
- `status`: Initial campaign status (default: PAUSED)
|
|
156
171
|
- `special_ad_categories`: List of special ad categories if applicable
|
|
157
172
|
- `daily_budget`: Daily budget in account currency (in cents)
|
|
158
173
|
- `lifetime_budget`: Lifetime budget in account currency (in cents)
|
|
174
|
+
- `bid_strategy`: Bid strategy. Must be one of: `LOWEST_COST_WITHOUT_CAP`, `LOWEST_COST_WITH_BID_CAP`, `COST_CAP`, `LOWEST_COST_WITH_MIN_ROAS`.
|
|
159
175
|
- Returns: Confirmation with new campaign details
|
|
160
176
|
|
|
177
|
+
- Example:
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"name": "2025 - Bedroom Furniture - Awareness",
|
|
181
|
+
"account_id": "act_123456789012345",
|
|
182
|
+
"objective": "OUTCOME_AWARENESS",
|
|
183
|
+
"special_ad_categories": [],
|
|
184
|
+
"status": "PAUSED",
|
|
185
|
+
"buying_type": "AUCTION",
|
|
186
|
+
"bid_strategy": "LOWEST_COST_WITHOUT_CAP",
|
|
187
|
+
"daily_budget": 10000
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
161
191
|
7. `mcp_meta_ads_get_adsets`
|
|
162
192
|
- Get ad sets for a Meta Ads account with optional filtering by campaign
|
|
163
193
|
- Inputs:
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Meta Ads MCP - Python Package
|
|
3
3
|
|
|
4
|
-
This package provides a Meta Ads
|
|
5
|
-
with the Claude LLM.
|
|
4
|
+
This package provides a Meta Ads MCP integration
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from meta_ads_mcp.core.server import main
|
|
9
8
|
|
|
10
|
-
__version__ = "0.10.
|
|
9
|
+
__version__ = "0.10.9"
|
|
11
10
|
|
|
12
11
|
__all__ = [
|
|
13
12
|
'get_ad_accounts',
|
|
@@ -671,7 +671,55 @@ async def upload_ad_image(
|
|
|
671
671
|
print(f"Uploading image to Facebook Ad Account {account_id}")
|
|
672
672
|
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
673
673
|
|
|
674
|
-
|
|
674
|
+
# Normalize/structure the response for callers (e.g., to easily grab image_hash)
|
|
675
|
+
# Typical Graph API response shape:
|
|
676
|
+
# { "images": { "<hash>": { "hash": "<hash>", "url": "...", "width": ..., "height": ..., "name": "...", "status": 1 } } }
|
|
677
|
+
if isinstance(data, dict) and "images" in data and isinstance(data["images"], dict) and data["images"]:
|
|
678
|
+
images_dict = data["images"]
|
|
679
|
+
images_list = []
|
|
680
|
+
for hash_key, info in images_dict.items():
|
|
681
|
+
# Some responses may omit the nested hash, so ensure it's present
|
|
682
|
+
normalized = {
|
|
683
|
+
"hash": (info.get("hash") or hash_key),
|
|
684
|
+
"url": info.get("url"),
|
|
685
|
+
"width": info.get("width"),
|
|
686
|
+
"height": info.get("height"),
|
|
687
|
+
"name": info.get("name"),
|
|
688
|
+
}
|
|
689
|
+
# Drop null/None values
|
|
690
|
+
normalized = {k: v for k, v in normalized.items() if v is not None}
|
|
691
|
+
images_list.append(normalized)
|
|
692
|
+
|
|
693
|
+
# Sort deterministically by hash
|
|
694
|
+
images_list.sort(key=lambda i: i.get("hash", ""))
|
|
695
|
+
primary_hash = images_list[0].get("hash") if images_list else None
|
|
696
|
+
|
|
697
|
+
result = {
|
|
698
|
+
"success": True,
|
|
699
|
+
"account_id": account_id,
|
|
700
|
+
"name": final_name,
|
|
701
|
+
"image_hash": primary_hash,
|
|
702
|
+
"images_count": len(images_list),
|
|
703
|
+
"images": images_list
|
|
704
|
+
}
|
|
705
|
+
return json.dumps(result, indent=2)
|
|
706
|
+
|
|
707
|
+
# If the API returned an error-like structure, surface it consistently
|
|
708
|
+
if isinstance(data, dict) and "error" in data:
|
|
709
|
+
return json.dumps({
|
|
710
|
+
"error": "Failed to upload image",
|
|
711
|
+
"details": data.get("error"),
|
|
712
|
+
"account_id": account_id,
|
|
713
|
+
"name": final_name
|
|
714
|
+
}, indent=2)
|
|
715
|
+
|
|
716
|
+
# Fallback: return a wrapped raw response to avoid breaking callers
|
|
717
|
+
return json.dumps({
|
|
718
|
+
"success": True,
|
|
719
|
+
"account_id": account_id,
|
|
720
|
+
"name": final_name,
|
|
721
|
+
"raw_response": data
|
|
722
|
+
}, indent=2)
|
|
675
723
|
|
|
676
724
|
except Exception as e:
|
|
677
725
|
return json.dumps({
|
|
@@ -109,13 +109,19 @@ async def create_campaign(
|
|
|
109
109
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
110
110
|
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
111
111
|
name: Campaign name
|
|
112
|
-
objective: Campaign objective
|
|
112
|
+
objective: Campaign objective (ODAX, outcome-based). Must be one of:
|
|
113
|
+
OUTCOME_AWARENESS, OUTCOME_TRAFFIC, OUTCOME_ENGAGEMENT,
|
|
114
|
+
OUTCOME_LEADS, OUTCOME_SALES, OUTCOME_APP_PROMOTION.
|
|
115
|
+
Note: Legacy objectives like BRAND_AWARENESS, LINK_CLICKS,
|
|
116
|
+
CONVERSIONS, APP_INSTALLS, etc. are not valid for new
|
|
117
|
+
campaigns and will cause a 400 error. Use the outcome-based
|
|
118
|
+
values above (e.g., BRAND_AWARENESS → OUTCOME_AWARENESS).
|
|
113
119
|
status: Initial campaign status (default: PAUSED)
|
|
114
120
|
special_ad_categories: List of special ad categories if applicable
|
|
115
121
|
daily_budget: Daily budget in account currency (in cents) as a string (only used if use_adset_level_budgets=False)
|
|
116
122
|
lifetime_budget: Lifetime budget in account currency (in cents) as a string (only used if use_adset_level_budgets=False)
|
|
117
123
|
buying_type: Buying type (e.g., 'AUCTION')
|
|
118
|
-
bid_strategy: Bid strategy
|
|
124
|
+
bid_strategy: Bid strategy. Must be one of: 'LOWEST_COST_WITHOUT_CAP', 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP', 'LOWEST_COST_WITH_MIN_ROAS'.
|
|
119
125
|
bid_cap: Bid cap in account currency (in cents) as a string
|
|
120
126
|
spend_cap: Spending limit for the campaign in account currency (in cents) as a string
|
|
121
127
|
campaign_budget_optimization: Whether to enable campaign budget optimization (only used if use_adset_level_budgets=False)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from meta_ads_mcp.core.ads import upload_ad_image
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_upload_ad_image_normalizes_images_dict():
|
|
11
|
+
mock_response = {
|
|
12
|
+
"images": {
|
|
13
|
+
"abc123": {
|
|
14
|
+
"hash": "abc123",
|
|
15
|
+
"url": "https://example.com/image.jpg",
|
|
16
|
+
"width": 1200,
|
|
17
|
+
"height": 628,
|
|
18
|
+
"name": "image.jpg",
|
|
19
|
+
"status": 1,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
|
|
25
|
+
mock_api.return_value = mock_response
|
|
26
|
+
|
|
27
|
+
# Use a data URL input to exercise that branch
|
|
28
|
+
file_data_url = "data:image/png;base64,QUJDREVGRw==" # 'ABCDEFG' base64
|
|
29
|
+
result_json = await upload_ad_image(
|
|
30
|
+
access_token="test",
|
|
31
|
+
account_id="act_123",
|
|
32
|
+
file=file_data_url,
|
|
33
|
+
name="my-upload.png",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
result = json.loads(result_json)
|
|
37
|
+
|
|
38
|
+
assert result.get("success") is True
|
|
39
|
+
assert result.get("account_id") == "act_123"
|
|
40
|
+
assert result.get("name") == "my-upload.png"
|
|
41
|
+
assert result.get("image_hash") == "abc123"
|
|
42
|
+
assert result.get("images_count") == 1
|
|
43
|
+
assert isinstance(result.get("images"), list) and result["images"][0]["hash"] == "abc123"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_upload_ad_image_error_structure_is_surfaced():
|
|
48
|
+
mock_response = {"error": {"message": "Something went wrong", "code": 400}}
|
|
49
|
+
|
|
50
|
+
with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
|
|
51
|
+
mock_api.return_value = mock_response
|
|
52
|
+
|
|
53
|
+
result_json = await upload_ad_image(
|
|
54
|
+
access_token="test",
|
|
55
|
+
account_id="act_123",
|
|
56
|
+
file="data:image/png;base64,QUJD",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Error responses from MCP functions may be wrapped multiple times under a data field
|
|
60
|
+
payload = result_json
|
|
61
|
+
for _ in range(5):
|
|
62
|
+
# If it's a JSON string, parse it
|
|
63
|
+
if isinstance(payload, str):
|
|
64
|
+
try:
|
|
65
|
+
payload = json.loads(payload)
|
|
66
|
+
continue
|
|
67
|
+
except Exception:
|
|
68
|
+
break
|
|
69
|
+
# If it's a dict containing a JSON string in data, unwrap once
|
|
70
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
71
|
+
payload = payload["data"]
|
|
72
|
+
continue
|
|
73
|
+
break
|
|
74
|
+
error_payload = payload if isinstance(payload, dict) else json.loads(payload)
|
|
75
|
+
|
|
76
|
+
assert "error" in error_payload
|
|
77
|
+
assert error_payload["error"] == "Failed to upload image"
|
|
78
|
+
assert isinstance(error_payload.get("details"), dict)
|
|
79
|
+
assert error_payload.get("account_id") == "act_123"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_upload_ad_image_fallback_wraps_raw_response():
|
|
84
|
+
mock_response = {"unexpected": "shape"}
|
|
85
|
+
|
|
86
|
+
with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
|
|
87
|
+
mock_api.return_value = mock_response
|
|
88
|
+
|
|
89
|
+
result_json = await upload_ad_image(
|
|
90
|
+
access_token="test",
|
|
91
|
+
account_id="act_123",
|
|
92
|
+
file="data:image/png;base64,QUJD",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
result = json.loads(result_json)
|
|
96
|
+
assert result.get("success") is True
|
|
97
|
+
assert result.get("raw_response") == mock_response
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_upload_ad_image_from_url_infers_name_and_prefixes_account_id():
|
|
102
|
+
mock_response = {
|
|
103
|
+
"images": {
|
|
104
|
+
"hash999": {
|
|
105
|
+
"url": "https://example.com/img.jpg",
|
|
106
|
+
"width": 800,
|
|
107
|
+
"height": 600,
|
|
108
|
+
# omit nested hash intentionally to test normalization fallback
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
with patch("meta_ads_mcp.core.ads.try_multiple_download_methods", new_callable=AsyncMock) as mock_dl, \
|
|
114
|
+
patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
|
|
115
|
+
mock_dl.return_value = b"\xff\xd8\xff" # minimal JPEG header bytes
|
|
116
|
+
mock_api.return_value = mock_response
|
|
117
|
+
|
|
118
|
+
# Provide raw account id (without act_) and ensure it is prefixed in the result
|
|
119
|
+
result_json = await upload_ad_image(
|
|
120
|
+
access_token="test",
|
|
121
|
+
account_id="701351919139047",
|
|
122
|
+
image_url="https://cdn.example.com/path/photo.jpg?x=1",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
result = json.loads(result_json)
|
|
126
|
+
assert result.get("success") is True
|
|
127
|
+
assert result.get("account_id") == "act_701351919139047"
|
|
128
|
+
# Name should be inferred from URL
|
|
129
|
+
assert result.get("name") == "photo.jpg"
|
|
130
|
+
# Primary hash should be derived from key when nested hash missing
|
|
131
|
+
assert result.get("image_hash") == "hash999"
|
|
132
|
+
assert result.get("images_count") == 1
|
|
133
|
+
|
|
134
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|