meta-ads-mcp 0.10.7__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.
Files changed (77) hide show
  1. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/PKG-INFO +31 -2
  2. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/README.md +30 -1
  3. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/__init__.py +2 -3
  4. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/ads.py +49 -1
  5. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/campaigns.py +7 -1
  6. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/pyproject.toml +1 -1
  7. meta_ads_mcp-0.10.9/tests/test_upload_ad_image.py +134 -0
  8. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/.github/workflows/publish.yml +0 -0
  9. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/.github/workflows/test.yml +0 -0
  10. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/.gitignore +0 -0
  11. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/CUSTOM_META_APP.md +0 -0
  12. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/Dockerfile +0 -0
  13. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/LICENSE +0 -0
  14. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/LOCAL_INSTALLATION.md +0 -0
  15. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/META_API_NOTES.md +0 -0
  16. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/RELEASE.md +0 -0
  17. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/STREAMABLE_HTTP_SETUP.md +0 -0
  18. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/examples/README.md +0 -0
  19. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/examples/example_http_client.py +0 -0
  20. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/future_improvements.md +0 -0
  21. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/images/meta-ads-example.png +0 -0
  22. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_auth.sh +0 -0
  23. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/__main__.py +0 -0
  24. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/__init__.py +0 -0
  25. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/accounts.py +0 -0
  26. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/ads_library.py +0 -0
  27. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/adsets.py +0 -0
  28. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/api.py +0 -0
  29. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/auth.py +0 -0
  30. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/authentication.py +0 -0
  31. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
  32. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/callback_server.py +0 -0
  33. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/duplication.py +0 -0
  34. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  35. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/insights.py +0 -0
  36. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  37. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  38. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/reports.py +0 -0
  39. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/resources.py +0 -0
  40. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/server.py +0 -0
  41. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/targeting.py +0 -0
  42. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/meta_ads_mcp/core/utils.py +0 -0
  43. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/requirements.txt +0 -0
  44. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/setup.py +0 -0
  45. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/smithery.yaml +0 -0
  46. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/README.md +0 -0
  47. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/README_REGRESSION_TESTS.md +0 -0
  48. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/__init__.py +0 -0
  49. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/conftest.py +0 -0
  50. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/e2e_account_info_search_issue.py +0 -0
  51. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_account_info_access_fix.py +0 -0
  52. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_account_search.py +0 -0
  53. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_budget_update.py +0 -0
  54. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_budget_update_e2e.py +0 -0
  55. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_dsa_beneficiary.py +0 -0
  56. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_dsa_integration.py +0 -0
  57. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_duplication.py +0 -0
  58. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_duplication_regression.py +0 -0
  59. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_dynamic_creatives.py +0 -0
  60. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_estimate_audience_size.py +0 -0
  61. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_estimate_audience_size_e2e.py +0 -0
  62. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_get_account_pages.py +0 -0
  63. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_get_ad_creatives_fix.py +0 -0
  64. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_get_ad_image_quality_improvements.py +0 -0
  65. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_get_ad_image_regression.py +0 -0
  66. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_http_transport.py +0 -0
  67. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_insights_actions_and_values.py +0 -0
  68. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_integration_openai_mcp.py +0 -0
  69. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_mobile_app_adset_creation.py +0 -0
  70. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_mobile_app_adset_issue.py +0 -0
  71. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_openai.py +0 -0
  72. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_openai_mcp_deep_research.py +0 -0
  73. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_page_discovery.py +0 -0
  74. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_page_discovery_integration.py +0 -0
  75. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_targeting.py +0 -0
  76. {meta_ads_mcp-0.10.7 → meta_ads_mcp-0.10.9}/tests/test_targeting_search_e2e.py +0 -0
  77. {meta_ads_mcp-0.10.7 → 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.7
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,7 +176,22 @@ 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 (AWARENESS, TRAFFIC, ENGAGEMENT, etc.)
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)
@@ -184,6 +199,20 @@ For local installation configuration, authentication options, and advanced techn
184
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`.
185
200
  - Returns: Confirmation with new campaign details
186
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
+
187
216
  7. `mcp_meta_ads_get_adsets`
188
217
  - Get ad sets for a Meta Ads account with optional filtering by campaign
189
218
  - Inputs:
@@ -151,7 +151,22 @@ 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 (AWARENESS, TRAFFIC, ENGAGEMENT, etc.)
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)
@@ -159,6 +174,20 @@ For local installation configuration, authentication options, and advanced techn
159
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`.
160
175
  - Returns: Confirmation with new campaign details
161
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
+
162
191
  7. `mcp_meta_ads_get_adsets`
163
192
  - Get ad sets for a Meta Ads account with optional filtering by campaign
164
193
  - Inputs:
@@ -1,13 +1,12 @@
1
1
  """
2
2
  Meta Ads MCP - Python Package
3
3
 
4
- This package provides a Meta Ads Marketing Cloud Platform (MCP) integration
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.7"
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
- return json.dumps(data, indent=2)
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,7 +109,13 @@ 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. Validates ad objectives. enum{BRAND_AWARENESS, LEAD_GENERATION, LINK_CLICKS, CONVERSIONS, OUTCOME_TRAFFIC, etc.}.
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.10.7"
7
+ version = "0.10.9"
8
8
  description = "Model Context Protocol (MCP) server for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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