Skip to content

Commit 7d14226

Browse files
authored
feat(campaigns): add adset_budgets to update_campaign for CBO to ABO migration, bump to 1.0.101 (#112)
Meta's documented mechanism for disabling the Advantage campaign budget and assigning per-ad-set budgets is a single POST to /CAMPAIGN_ID with adset_budgets=[{adset_id, daily_budget}, ...]. The previous workaround of sending empty daily_budget/lifetime_budget via use_adset_level_budgets=true returns success but Meta silently ignores the empty values, so the campaign-level budget persists and ad set budget updates keep failing with Budget Conflict. Add adset_budgets parameter, document the migration in the tool description, and clarify in the response note that use_adset_level_budgets is not reliable for the migration.
1 parent 9e2f503 commit 7d14226

4 files changed

Lines changed: 114 additions & 9 deletions

File tree

‎.email-allowlist‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ info@pipeboard.co
1313
# pytest decorator false-positive: @pytest.mark.asyncio matches the email regex
1414
# because the diff +@pytest.mark.asyncio has + in the character class
1515
pytest.mark.asyncio
16+
pytest.fixture

‎meta_ads_mcp/core/campaigns.py‎

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,28 +276,42 @@ async def update_campaign(
276276
campaign_budget_optimization: Optional[bool] = None,
277277
objective: Optional[str] = None, # Add objective if it's updatable
278278
use_adset_level_budgets: Optional[bool] = None, # Add other updatable fields as needed based on API docs
279+
adset_budgets: Optional[List[Dict[str, Any]]] = None,
279280
) -> str:
280281
"""
281282
Update an existing campaign in a Meta Ads account.
282283
283284
Note: Campaigns do not support start_time for scheduling — set start_time on the ad set instead.
284285
286+
Migrating CBO (Advantage Campaign Budget) → ABO (ad set level budgets):
287+
Pass `adset_budgets` with one entry per ad set in the campaign. Meta atomically
288+
removes the campaign-level budget and assigns budgets at the ad set level in a
289+
single call. This is Meta's documented mechanism — the legacy
290+
`use_adset_level_budgets=true` flag attempts to clear `daily_budget`/`lifetime_budget`
291+
but Meta silently ignores the empty values, so the migration does not persist.
292+
285293
Args:
286294
campaign_id: Meta Ads campaign ID
287295
access_token: Meta API access token (optional - will use cached token if not provided)
288296
name: New campaign name
289297
status: New campaign status (e.g., 'ACTIVE', 'PAUSED')
290298
special_ad_categories: List of special ad categories if applicable
291-
daily_budget: New daily budget in account currency (in cents) as a string.
292-
Set to empty string "" to remove the daily budget.
293-
lifetime_budget: New lifetime budget in account currency (in cents) as a string.
294-
Set to empty string "" to remove the lifetime budget.
299+
daily_budget: New daily budget in account currency (in cents).
300+
lifetime_budget: New lifetime budget in account currency (in cents).
295301
bid_strategy: New bid strategy
296302
bid_cap: New bid cap in account currency (in cents) as a string
297303
spend_cap: New spending limit for the campaign in account currency (in cents) as a string
298304
campaign_budget_optimization: Enable/disable campaign budget optimization
299305
objective: New campaign objective (Note: May not always be updatable)
300-
use_adset_level_budgets: If True, removes campaign-level budgets to switch to ad set level budgets
306+
use_adset_level_budgets: Deprecated for CBO → ABO migration — use `adset_budgets`
307+
instead. Kept for backwards compatibility; sends empty `daily_budget`/
308+
`lifetime_budget` which Meta silently ignores in most cases.
309+
adset_budgets: List of `{"adset_id": "...", "daily_budget": <cents>}` objects.
310+
Use to migrate from CBO to ABO: Meta removes the campaign-level Advantage
311+
budget and assigns the provided daily budgets at the ad set level in one
312+
atomic call. Example:
313+
[{"adset_id": "1234", "daily_budget": 5000},
314+
{"adset_id": "5678", "daily_budget": 7000}]
301315
"""
302316
if not campaign_id:
303317
return json.dumps({"error": "No campaign ID provided"}, indent=2)
@@ -364,6 +378,12 @@ async def update_campaign(
364378
if objective is not None:
365379
params["objective"] = objective # Caution: Objective changes might reset learning or be restricted
366380

381+
# adset_budgets migrates CBO → ABO atomically: Meta removes the campaign-level
382+
# budget and assigns the per-ad-set daily budgets in one call.
383+
# make_api_request JSON-encodes lists for POST form data.
384+
if adset_budgets is not None:
385+
params["adset_budgets"] = adset_budgets
386+
367387
if not params:
368388
return json.dumps({"error": "No update parameters provided"}, indent=2)
369389

@@ -372,10 +392,21 @@ async def update_campaign(
372392
data = await make_api_request(endpoint, access_token, params, method="POST")
373393

374394
# Add a note about budget strategy if switching to ad set level budgets
375-
if use_adset_level_budgets is not None and use_adset_level_budgets:
395+
if adset_budgets is not None:
376396
data["budget_strategy"] = "ad_set_level"
377-
data["note"] = "Campaign updated to use ad set level budgets. Set budgets when creating ad sets within this campaign."
378-
397+
data["note"] = (
398+
"Campaign migrated to ad set level budgets via adset_budgets. "
399+
"The campaign-level Advantage budget has been removed and the provided "
400+
"daily budgets have been assigned to each ad set."
401+
)
402+
elif use_adset_level_budgets is not None and use_adset_level_budgets:
403+
data["budget_strategy"] = "ad_set_level"
404+
data["note"] = (
405+
"use_adset_level_budgets=true sent empty daily_budget/lifetime_budget "
406+
"values; Meta typically ignores these. To reliably migrate CBO → ABO, "
407+
"call update_campaign with adset_budgets=[{adset_id, daily_budget}, ...]."
408+
)
409+
379410
return json.dumps(data, indent=2)
380411
except Exception as e:
381412
error_msg = str(e)

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meta-ads-mcp"
7-
version = "1.0.100"
7+
version = "1.0.101"
88
description = "Model Context Protocol (MCP) server for Meta Ads - Use Remote MCP at pipeboard.co for easiest setup"
99
readme = "README.md"
1010
requires-python = ">=3.10"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Unit tests for update_campaign adset_budgets (CBO → ABO migration)."""
2+
3+
import json
4+
import pytest
5+
from unittest.mock import patch
6+
7+
from meta_ads_mcp.core.campaigns import update_campaign
8+
9+
10+
@pytest.fixture
11+
def mock_api_request():
12+
with patch("meta_ads_mcp.core.campaigns.make_api_request") as mock:
13+
mock.return_value = {"success": True, "id": "120243420658870518"}
14+
yield mock
15+
16+
17+
@pytest.fixture
18+
def mock_auth():
19+
with patch("meta_ads_mcp.core.api.auth_manager") as mgr, \
20+
patch("meta_ads_mcp.core.auth.get_current_access_token") as get_token:
21+
mgr.get_current_access_token.return_value = "tok"
22+
mgr.is_token_valid.return_value = True
23+
mgr.app_id = "app"
24+
get_token.return_value = "tok"
25+
yield mgr
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_adset_budgets_forwarded_as_list(mock_api_request, mock_auth):
30+
budgets = [
31+
{"adset_id": "111", "daily_budget": 5000},
32+
{"adset_id": "222", "daily_budget": 7000},
33+
]
34+
result = await update_campaign(
35+
campaign_id="120243420658870518",
36+
adset_budgets=budgets,
37+
)
38+
39+
mock_api_request.assert_called_once()
40+
args, kwargs = mock_api_request.call_args
41+
sent_params = args[2] if len(args) > 2 else kwargs.get("params") or kwargs
42+
assert sent_params["adset_budgets"] == budgets
43+
44+
data = json.loads(result)
45+
assert data["budget_strategy"] == "ad_set_level"
46+
assert "adset_budgets" in data["note"]
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_adset_budgets_not_sent_when_omitted(mock_api_request, mock_auth):
51+
await update_campaign(
52+
campaign_id="120243420658870518",
53+
name="renamed",
54+
)
55+
args, kwargs = mock_api_request.call_args
56+
sent_params = args[2] if len(args) > 2 else kwargs.get("params") or kwargs
57+
assert "adset_budgets" not in sent_params
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_use_adset_level_budgets_still_works_for_backcompat(mock_api_request, mock_auth):
62+
result = await update_campaign(
63+
campaign_id="120243420658870518",
64+
use_adset_level_budgets=True,
65+
)
66+
args, kwargs = mock_api_request.call_args
67+
sent_params = args[2] if len(args) > 2 else kwargs.get("params") or kwargs
68+
assert sent_params["daily_budget"] == ""
69+
assert sent_params["lifetime_budget"] == ""
70+
71+
data = json.loads(result)
72+
assert data["budget_strategy"] == "ad_set_level"
73+
assert "adset_budgets" in data["note"]

0 commit comments

Comments
 (0)