create_adset
Create an ad set in Meta Ads by configuring campaign, budget, targeting, optimization goal, and bid strategy.
Instructions
Create a new ad set in a Meta Ads account.
Args:
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
campaign_id: Meta Ads campaign ID this ad set belongs to
name: Ad set name
optimization_goal: Conversion optimization goal. Valid values depend on the campaign objective and destination_type.
OUTCOME_ENGAGEMENT + destination_type=WEBSITE: OFFSITE_CONVERSIONS, LANDING_PAGE_VIEWS, LINK_CLICKS, IMPRESSIONS, REACH.
OUTCOME_ENGAGEMENT + On Post: POST_ENGAGEMENT, IMPRESSIONS, REACH.
OUTCOME_ENGAGEMENT + On Video: THRUPLAY, TWO_SECOND_CONTINUOUS_VIDEO_VIEWS.
OUTCOME_ENGAGEMENT + On Event: EVENT_RESPONSES, IMPRESSIONS, POST_ENGAGEMENT, REACH.
OUTCOME_ENGAGEMENT + On Page: PAGE_LIKES.
OUTCOME_ENGAGEMENT + Messaging (MESSENGER/WHATSAPP/INSTAGRAM_DIRECT): CONVERSATIONS, LINK_CLICKS.
OUTCOME_TRAFFIC + WEBSITE: LANDING_PAGE_VIEWS, LINK_CLICKS, IMPRESSIONS, REACH.
OUTCOME_AWARENESS: REACH, IMPRESSIONS, AD_RECALL_LIFT, THRUPLAY.
OUTCOME_LEADS: LEAD_GENERATION, QUALITY_LEAD (forms), QUALITY_CALL (calls), OFFSITE_CONVERSIONS, LINK_CLICKS (website).
OUTCOME_SALES: OFFSITE_CONVERSIONS, VALUE, CONVERSATIONS, LINK_CLICKS, IMPRESSIONS, REACH.
OUTCOME_APP_PROMOTION: APP_INSTALLS, APP_INSTALLS_AND_OFFSITE_CONVERSIONS, VALUE.
billing_event: How you're charged (e.g., 'IMPRESSIONS', 'LINK_CLICKS')
status: Initial ad set status (default: PAUSED)
daily_budget: Daily budget in account currency (in cents) as a string.
CBO NOTE: Do NOT set this if the parent campaign already has a budget
(Campaign Budget Optimization / CBO mode). Meta only allows budgets at one
level: either the campaign OR the ad set, not both. If the campaign has a
daily_budget or lifetime_budget, omit this field — the ad set will
automatically use the campaign budget.
lifetime_budget: Lifetime budget in account currency (in cents) as a string.
CBO NOTE: Do NOT set this if the parent campaign already has a budget
(Campaign Budget Optimization / CBO mode). Omit this field when the
campaign uses CBO — the ad set inherits the campaign budget automatically.
targeting: Targeting specs (age, location, interests, etc).
targeting_automation.advantage_audience defaults to 0 if not set (Meta API v24+ requirement).
Set to 1 to enable Advantage+ Audience (requires age_max>=65). Use search_interests for interest IDs.
bid_amount: Bid amount in account currency (in cents).
REQUIRED for: LOWEST_COST_WITH_BID_CAP, COST_CAP, TARGET_COST.
NOT USED by: LOWEST_COST_WITH_MIN_ROAS (uses bid_constraints instead).
May also be required if the parent campaign's bid strategy requires it.
bid_strategy: Bid strategy. Valid values:
- 'LOWEST_COST_WITHOUT_CAP' (recommended) - no bid_amount required
- 'LOWEST_COST_WITH_BID_CAP' - REQUIRES bid_amount
- 'COST_CAP' - REQUIRES bid_amount
- 'LOWEST_COST_WITH_MIN_ROAS' - REQUIRES bid_constraints with roas_average_floor,
and optimization_goal='VALUE'. Does NOT use bid_amount.
Note: 'LOWEST_COST' is NOT valid - use 'LOWEST_COST_WITHOUT_CAP'.
Campaign-level bid strategy may constrain ad set choices.
bid_constraints: Bid constraints dict. Required for LOWEST_COST_WITH_MIN_ROAS.
Use {"roas_average_floor": <value>} where value = target ROAS * 10000.
Example: 2.0x ROAS -> {"roas_average_floor": 20000}
bid_adjustments: Bid multipliers per targeting dimension. Pass-through to Meta.
Shape: {"user_groups": {"<dim>": {"<value>": <float>, "default": <float>}}}
Dims: age, gender, user_os, device_platform, position_type,
publisher_platform, user_bucket, home_location, locale, etc.
Multipliers are floats, typically 0.0-1.0.
Example: {"user_groups": {"user_os": {"iOS": 0.9, "Android": 0.7, "default": 1.0}}}
NOTE: Writing bid_adjustments requires a Meta app capability that must be
allowlisted. Apps without it get OAuthException (#3).
start_time: Start time in ISO 8601 format (e.g., '2023-12-01T12:00:00-0800').
To schedule future delivery: set start_time to a future date and status=ACTIVE.
Meta will show effective_status as SCHEDULED and automatically begin delivery at start_time.
NOTE: Only ad set start_time controls delivery scheduling. Campaigns do not support start_time.
end_time: End time in ISO 8601 format. Required when lifetime_budget is specified.
dsa_beneficiary: DSA beneficiary for European compliance (person/org that benefits from ads).
Required for EU-targeted ad sets along with dsa_payor.
dsa_payor: DSA payor for European compliance (person/org paying for the ads).
Required for EU-targeted ad sets along with dsa_beneficiary.
promoted_object: App config for APP_INSTALLS. Required: application_id, object_store_url.
destination_type: Where users go after click. Common values: 'WEBSITE', 'WHATSAPP', 'MESSENGER',
'INSTAGRAM_DIRECT', 'ON_AD', 'APP', 'FACEBOOK', 'SHOP_AUTOMATIC'.
Also supports multi-channel combos like 'MESSAGING_MESSENGER_WHATSAPP'.
is_dynamic_creative: Enable Dynamic Creative for this ad set.
frequency_control_specs: Frequency cap specs. MUST be set at creation time — Meta makes this field
immutable after the ad set is created (error 1815198).
Only works with OUTCOME_AWARENESS campaigns + optimization_goal REACH or THRUPLAY.
Example: [{"event": "IMPRESSIONS", "interval_days": 7, "max_frequency": 1}]
multi_advertiser_ads: Set to 0 to opt out of Multi-Advertiser Ads, 1 to opt in.
This is a TOP-LEVEL ad set parameter — do NOT put it inside the targeting object.
regional_regulated_categories: List of regional regulated categories for the ad set.
Required for ads targeting regulated regions (Taiwan, Australia, etc.).
Valid values: TAIWAN_FINSERV, TAIWAN_UNIVERSAL, AUSTRALIA_FINSERV,
INDIA_FINSERV, SINGAPORE_UNIVERSAL, THAILAND_UNIVERSAL.
Example: ["TAIWAN_UNIVERSAL"] or ["TAIWAN_FINSERV", "TAIWAN_UNIVERSAL"]
regional_regulation_identities: Dict of verified identity IDs for regional transparency compliance.
Required when regional_regulated_categories is set.
The identity IDs come from completing advertiser verification in Meta Business Settings.
Keys depend on the categories declared:
- TAIWAN_UNIVERSAL: taiwan_universal_beneficiary, taiwan_universal_payer
- TAIWAN_FINSERV: taiwan_finserv_beneficiary, taiwan_finserv_payer
- AUSTRALIA_FINSERV: australia_finserv_beneficiary, australia_finserv_payer
- SINGAPORE_UNIVERSAL: singapore_universal_beneficiary, singapore_universal_payer
Example: {"taiwan_universal_beneficiary": "<id>", "taiwan_universal_payer": "<id>"}
attribution_spec: Attribution window specification for the ad set. Controls how conversions are
attributed to ads. Default is 7-day click if not specified.
Example for 1-day click: [{"event_type": "CLICK_THROUGH", "window_days": 1}]
Example for 1-day click + 1-day view: [{"event_type": "CLICK_THROUGH", "window_days": 1}, {"event_type": "VIEW_THROUGH", "window_days": 1}]
Valid event_type values: CLICK_THROUGH, VIEW_THROUGH.
Valid window_days values: 1, 7, 28 (depends on event_type and optimization_goal).
access_token: Meta API access token (optional - will use cached token if not provided)Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| account_id | Yes | ||
| campaign_id | Yes | ||
| name | Yes | ||
| optimization_goal | Yes | ||
| billing_event | Yes | ||
| status | No | PAUSED | |
| daily_budget | No | ||
| lifetime_budget | No | ||
| targeting | No | ||
| bid_amount | No | ||
| bid_strategy | No | ||
| bid_constraints | No | ||
| bid_adjustments | No | ||
| start_time | No | ||
| end_time | No | ||
| dsa_beneficiary | No | ||
| dsa_payor | No | ||
| promoted_object | No | ||
| destination_type | No | ||
| is_dynamic_creative | No | ||
| frequency_control_specs | No | ||
| multi_advertiser_ads | No | ||
| regional_regulated_categories | No | ||
| regional_regulation_identities | No | ||
| attribution_spec | No | ||
| access_token | No |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| result | Yes |
Implementation Reference
- meta_ads_mcp/core/adsets.py:86-484 (handler)The create_adset function implementation. This is the main handler for the create_adset tool. It's decorated with @mcp_server.tool() (line 86) and @meta_api_tool (line 87), takes all ad set parameters, validates them (CBO budget conflict check, bid strategy validation, mobile app params validation), constructs the API request, and calls the Meta Graph API via make_api_request with method='POST'. The function returns a JSON string response.
@mcp_server.tool() @meta_api_tool async def create_adset( account_id: str, campaign_id: str, name: str, optimization_goal: str, billing_event: str, status: str = "PAUSED", daily_budget: Optional[int] = None, lifetime_budget: Optional[int] = None, targeting: Optional[Dict[str, Any]] = None, bid_amount: Optional[int] = None, bid_strategy: Optional[str] = None, bid_constraints: Optional[Dict[str, Any]] = None, bid_adjustments: Optional[Dict[str, Any]] = None, start_time: Optional[str] = None, end_time: Optional[str] = None, dsa_beneficiary: Optional[str] = None, dsa_payor: Optional[str] = None, promoted_object: Optional[Dict[str, Any]] = None, destination_type: Optional[str] = None, is_dynamic_creative: Optional[bool] = None, frequency_control_specs: Optional[List[Dict[str, Any]]] = None, multi_advertiser_ads: Optional[int] = None, regional_regulated_categories: Optional[List[str]] = None, regional_regulation_identities: Optional[Dict[str, Any]] = None, attribution_spec: Optional[List[Dict[str, Any]]] = None, access_token: Optional[str] = None ) -> str: """ Create a new ad set in a Meta Ads account. Args: account_id: Meta Ads account ID (format: act_XXXXXXXXX) campaign_id: Meta Ads campaign ID this ad set belongs to name: Ad set name optimization_goal: Conversion optimization goal. Valid values depend on the campaign objective and destination_type. OUTCOME_ENGAGEMENT + destination_type=WEBSITE: OFFSITE_CONVERSIONS, LANDING_PAGE_VIEWS, LINK_CLICKS, IMPRESSIONS, REACH. OUTCOME_ENGAGEMENT + On Post: POST_ENGAGEMENT, IMPRESSIONS, REACH. OUTCOME_ENGAGEMENT + On Video: THRUPLAY, TWO_SECOND_CONTINUOUS_VIDEO_VIEWS. OUTCOME_ENGAGEMENT + On Event: EVENT_RESPONSES, IMPRESSIONS, POST_ENGAGEMENT, REACH. OUTCOME_ENGAGEMENT + On Page: PAGE_LIKES. OUTCOME_ENGAGEMENT + Messaging (MESSENGER/WHATSAPP/INSTAGRAM_DIRECT): CONVERSATIONS, LINK_CLICKS. OUTCOME_TRAFFIC + WEBSITE: LANDING_PAGE_VIEWS, LINK_CLICKS, IMPRESSIONS, REACH. OUTCOME_AWARENESS: REACH, IMPRESSIONS, AD_RECALL_LIFT, THRUPLAY. OUTCOME_LEADS: LEAD_GENERATION, QUALITY_LEAD (forms), QUALITY_CALL (calls), OFFSITE_CONVERSIONS, LINK_CLICKS (website). OUTCOME_SALES: OFFSITE_CONVERSIONS, VALUE, CONVERSATIONS, LINK_CLICKS, IMPRESSIONS, REACH. OUTCOME_APP_PROMOTION: APP_INSTALLS, APP_INSTALLS_AND_OFFSITE_CONVERSIONS, VALUE. billing_event: How you're charged (e.g., 'IMPRESSIONS', 'LINK_CLICKS') status: Initial ad set status (default: PAUSED) daily_budget: Daily budget in account currency (in cents) as a string. CBO NOTE: Do NOT set this if the parent campaign already has a budget (Campaign Budget Optimization / CBO mode). Meta only allows budgets at one level: either the campaign OR the ad set, not both. If the campaign has a daily_budget or lifetime_budget, omit this field — the ad set will automatically use the campaign budget. lifetime_budget: Lifetime budget in account currency (in cents) as a string. CBO NOTE: Do NOT set this if the parent campaign already has a budget (Campaign Budget Optimization / CBO mode). Omit this field when the campaign uses CBO — the ad set inherits the campaign budget automatically. targeting: Targeting specs (age, location, interests, etc). targeting_automation.advantage_audience defaults to 0 if not set (Meta API v24+ requirement). Set to 1 to enable Advantage+ Audience (requires age_max>=65). Use search_interests for interest IDs. bid_amount: Bid amount in account currency (in cents). REQUIRED for: LOWEST_COST_WITH_BID_CAP, COST_CAP, TARGET_COST. NOT USED by: LOWEST_COST_WITH_MIN_ROAS (uses bid_constraints instead). May also be required if the parent campaign's bid strategy requires it. bid_strategy: Bid strategy. Valid values: - 'LOWEST_COST_WITHOUT_CAP' (recommended) - no bid_amount required - 'LOWEST_COST_WITH_BID_CAP' - REQUIRES bid_amount - 'COST_CAP' - REQUIRES bid_amount - 'LOWEST_COST_WITH_MIN_ROAS' - REQUIRES bid_constraints with roas_average_floor, and optimization_goal='VALUE'. Does NOT use bid_amount. Note: 'LOWEST_COST' is NOT valid - use 'LOWEST_COST_WITHOUT_CAP'. Campaign-level bid strategy may constrain ad set choices. bid_constraints: Bid constraints dict. Required for LOWEST_COST_WITH_MIN_ROAS. Use {"roas_average_floor": <value>} where value = target ROAS * 10000. Example: 2.0x ROAS -> {"roas_average_floor": 20000} bid_adjustments: Bid multipliers per targeting dimension. Pass-through to Meta. Shape: {"user_groups": {"<dim>": {"<value>": <float>, "default": <float>}}} Dims: age, gender, user_os, device_platform, position_type, publisher_platform, user_bucket, home_location, locale, etc. Multipliers are floats, typically 0.0-1.0. Example: {"user_groups": {"user_os": {"iOS": 0.9, "Android": 0.7, "default": 1.0}}} NOTE: Writing bid_adjustments requires a Meta app capability that must be allowlisted. Apps without it get OAuthException (#3). start_time: Start time in ISO 8601 format (e.g., '2023-12-01T12:00:00-0800'). To schedule future delivery: set start_time to a future date and status=ACTIVE. Meta will show effective_status as SCHEDULED and automatically begin delivery at start_time. NOTE: Only ad set start_time controls delivery scheduling. Campaigns do not support start_time. end_time: End time in ISO 8601 format. Required when lifetime_budget is specified. dsa_beneficiary: DSA beneficiary for European compliance (person/org that benefits from ads). Required for EU-targeted ad sets along with dsa_payor. dsa_payor: DSA payor for European compliance (person/org paying for the ads). Required for EU-targeted ad sets along with dsa_beneficiary. promoted_object: App config for APP_INSTALLS. Required: application_id, object_store_url. destination_type: Where users go after click. Common values: 'WEBSITE', 'WHATSAPP', 'MESSENGER', 'INSTAGRAM_DIRECT', 'ON_AD', 'APP', 'FACEBOOK', 'SHOP_AUTOMATIC'. Also supports multi-channel combos like 'MESSAGING_MESSENGER_WHATSAPP'. is_dynamic_creative: Enable Dynamic Creative for this ad set. frequency_control_specs: Frequency cap specs. MUST be set at creation time — Meta makes this field immutable after the ad set is created (error 1815198). Only works with OUTCOME_AWARENESS campaigns + optimization_goal REACH or THRUPLAY. Example: [{"event": "IMPRESSIONS", "interval_days": 7, "max_frequency": 1}] multi_advertiser_ads: Set to 0 to opt out of Multi-Advertiser Ads, 1 to opt in. This is a TOP-LEVEL ad set parameter — do NOT put it inside the targeting object. regional_regulated_categories: List of regional regulated categories for the ad set. Required for ads targeting regulated regions (Taiwan, Australia, etc.). Valid values: TAIWAN_FINSERV, TAIWAN_UNIVERSAL, AUSTRALIA_FINSERV, INDIA_FINSERV, SINGAPORE_UNIVERSAL, THAILAND_UNIVERSAL. Example: ["TAIWAN_UNIVERSAL"] or ["TAIWAN_FINSERV", "TAIWAN_UNIVERSAL"] regional_regulation_identities: Dict of verified identity IDs for regional transparency compliance. Required when regional_regulated_categories is set. The identity IDs come from completing advertiser verification in Meta Business Settings. Keys depend on the categories declared: - TAIWAN_UNIVERSAL: taiwan_universal_beneficiary, taiwan_universal_payer - TAIWAN_FINSERV: taiwan_finserv_beneficiary, taiwan_finserv_payer - AUSTRALIA_FINSERV: australia_finserv_beneficiary, australia_finserv_payer - SINGAPORE_UNIVERSAL: singapore_universal_beneficiary, singapore_universal_payer Example: {"taiwan_universal_beneficiary": "<id>", "taiwan_universal_payer": "<id>"} attribution_spec: Attribution window specification for the ad set. Controls how conversions are attributed to ads. Default is 7-day click if not specified. Example for 1-day click: [{"event_type": "CLICK_THROUGH", "window_days": 1}] Example for 1-day click + 1-day view: [{"event_type": "CLICK_THROUGH", "window_days": 1}, {"event_type": "VIEW_THROUGH", "window_days": 1}] Valid event_type values: CLICK_THROUGH, VIEW_THROUGH. Valid window_days values: 1, 7, 28 (depends on event_type and optimization_goal). access_token: Meta API access token (optional - will use cached token if not provided) """ # Check required parameters if not account_id: return json.dumps({"error": "No account ID provided"}, indent=2) account_id = ensure_act_prefix(account_id) if not campaign_id: return json.dumps({"error": "No campaign ID provided"}, indent=2) if not name: return json.dumps({"error": "No ad set name provided"}, indent=2) if not optimization_goal: return json.dumps({"error": "No optimization goal provided"}, indent=2) if not billing_event: return json.dumps({"error": "No billing event provided"}, indent=2) # Validate mobile app parameters for APP_INSTALLS campaigns if optimization_goal == "APP_INSTALLS": if not promoted_object: return json.dumps({ "error": "promoted_object is required for APP_INSTALLS optimization goal", "details": "Mobile app campaigns must specify which app is being promoted", "required_fields": ["application_id", "object_store_url"] }, indent=2) # Validate promoted_object structure if not isinstance(promoted_object, dict): return json.dumps({ "error": "promoted_object must be a dictionary", "example": {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"} }, indent=2) # Validate required promoted_object fields if "application_id" not in promoted_object: return json.dumps({ "error": "promoted_object missing required field: application_id", "details": "application_id is the Facebook app ID for your mobile app" }, indent=2) if "object_store_url" not in promoted_object: return json.dumps({ "error": "promoted_object missing required field: object_store_url", "details": "object_store_url should be the App Store or Google Play URL for your app" }, indent=2) # Validate store URL format store_url = promoted_object["object_store_url"] valid_store_patterns = [ "apps.apple.com", # iOS App Store "play.google.com", # Google Play Store "itunes.apple.com" # Alternative iOS format ] if not any(pattern in store_url for pattern in valid_store_patterns): return json.dumps({ "error": "Invalid object_store_url format", "details": "URL must be from App Store (apps.apple.com) or Google Play (play.google.com)", "provided_url": store_url }, indent=2) # destination_type is passed through to Meta's API without client-side validation. # Meta supports 23+ values (WHATSAPP, MESSENGER, INSTAGRAM_DIRECT, ON_AD, WEBSITE, # APP, FACEBOOK, SHOP_AUTOMATIC, multi-channel MESSAGING_* combos, etc.) # and may add more. Let Meta's API reject invalid values. # See: facebook-python-business-sdk AdSet.DestinationType # Basic targeting is required if not provided if not targeting: targeting = { "age_min": 18, "age_max": 65, "geo_locations": {"countries": ["US"]}, "targeting_automation": {"advantage_audience": 1} } # Meta API v24+ requires targeting_automation.advantage_audience. # Default to 0 (disabled) when user provides custom targeting, since # advantage_audience=1 enforces constraints (e.g. age_max >= 65) that # conflict with explicit targeting parameters. if "targeting_automation" not in targeting: targeting["targeting_automation"] = {"advantage_audience": 0} # Bid strategies that require bid_amount (not bid_constraints) strategies_requiring_bid_amount = [ 'LOWEST_COST_WITH_BID_CAP', 'COST_CAP', 'TARGET_COST', ] # Validate bid_strategy and bid_amount requirements if bid_strategy: # Check for invalid 'LOWEST_COST' value (common mistake) if bid_strategy == 'LOWEST_COST': return json.dumps({ "error": "'LOWEST_COST' is not a valid bid_strategy value", "details": "The 'LOWEST_COST' bid strategy is not valid in Meta Ads API v24.0", "workaround": "Use 'LOWEST_COST_WITHOUT_CAP' instead (no bid_amount required)", "valid_values": [ "LOWEST_COST_WITHOUT_CAP (recommended - no bid_amount required)", "LOWEST_COST_WITH_BID_CAP (requires bid_amount)", "COST_CAP (requires bid_amount)", "LOWEST_COST_WITH_MIN_ROAS (requires bid_constraints with roas_average_floor)" ], "example": '{"bid_strategy": "LOWEST_COST_WITHOUT_CAP"}' }, indent=2) if bid_strategy in strategies_requiring_bid_amount and bid_amount is None: return json.dumps({ "error": f"bid_amount is required when using bid_strategy '{bid_strategy}'", "details": f"The '{bid_strategy}' bid strategy requires you to specify a bid amount in cents", "workaround": "Either provide the bid_amount parameter, or use bid_strategy='LOWEST_COST_WITHOUT_CAP' which does not require a bid amount", "example_with_bid_amount": f'{{"bid_strategy": "{bid_strategy}", "bid_amount": 500}}', "example_without_bid_amount": '{"bid_strategy": "LOWEST_COST_WITHOUT_CAP"}' }, indent=2) # LOWEST_COST_WITH_MIN_ROAS requires bid_constraints with roas_average_floor if bid_strategy == 'LOWEST_COST_WITH_MIN_ROAS' and not bid_constraints: return json.dumps({ "error": "bid_constraints is required when using bid_strategy 'LOWEST_COST_WITH_MIN_ROAS'", "details": "Provide bid_constraints with roas_average_floor (target ROAS * 10000)", "example": '{"bid_strategy": "LOWEST_COST_WITH_MIN_ROAS", "bid_constraints": {"roas_average_floor": 20000}, "optimization_goal": "VALUE"}' }, indent=2) # Pre-flight check: fetch campaign data to catch common errors before hitting Meta's API. # Triggered when the user provides a budget (CBO conflict check) or omits bid_amount # (bid strategy compatibility check). A single API call covers both checks. needs_campaign_check = (daily_budget is not None or lifetime_budget is not None or bid_amount is None) if needs_campaign_check: try: campaign_data = await make_api_request( campaign_id, access_token, {"fields": "bid_strategy,name,daily_budget,lifetime_budget"} ) campaign_name = campaign_data.get("name", campaign_id) # Check 1: CBO budget conflict. # Meta does not allow budgets at both the campaign and ad set level. # If the campaign already has a budget (CBO mode), reject ad-set-level budgets early. if daily_budget is not None or lifetime_budget is not None: campaign_daily_budget = campaign_data.get("daily_budget") campaign_lifetime_budget = campaign_data.get("lifetime_budget") if campaign_daily_budget or campaign_lifetime_budget: budget_type = "daily_budget" if campaign_daily_budget else "lifetime_budget" return json.dumps({ "error": f"Budget conflict: campaign '{campaign_name}' ({campaign_id}) already has a {budget_type} set (Campaign Budget Optimization / CBO).", "details": "Meta does not allow budgets at both the campaign and ad set level. When a campaign uses CBO, its ad sets must not specify daily_budget or lifetime_budget.", "fix": "Remove daily_budget and lifetime_budget from your create_adset call. The ad set will automatically use the campaign budget.", "alternative": "To use ad set-level budgets (ABO), you would need to create a campaign without a campaign-level budget." }, indent=2) # Check 2: Campaign bid strategy requires bid_amount. # This prevents a confusing error from Meta's API when the campaign-level # bid strategy forces child ad sets to provide bid_amount. if bid_amount is None: campaign_bid_strategy = campaign_data.get("bid_strategy") if campaign_bid_strategy and campaign_bid_strategy in strategies_requiring_bid_amount: return json.dumps({ "error": f"bid_amount is required because the parent campaign uses bid_strategy '{campaign_bid_strategy}'", "details": f"Campaign '{campaign_name}' ({campaign_id}) uses '{campaign_bid_strategy}', which requires all child ad sets to provide a bid_amount (in cents).", "workaround": "Either provide the bid_amount parameter, or change the campaign's bid_strategy to 'LOWEST_COST_WITHOUT_CAP'", "example_with_bid_amount": f'{{"bid_amount": 500}} (= $5.00 bid cap)', "example_without_bid_amount": 'Change campaign bid strategy: update_campaign(campaign_id="' + campaign_id + '", bid_strategy="LOWEST_COST_WITHOUT_CAP")' }, indent=2) except Exception: pass # If the pre-flight check fails, let the create request proceed normally endpoint = f"{account_id}/adsets" params = { "name": name, "campaign_id": campaign_id, "status": status, "optimization_goal": optimization_goal, "billing_event": billing_event, "targeting": json.dumps(targeting) # Properly format as JSON string } # Convert budget values to strings if they aren't already if daily_budget is not None: params["daily_budget"] = str(daily_budget) if lifetime_budget is not None: params["lifetime_budget"] = str(lifetime_budget) # Add other parameters if provided if bid_amount is not None: params["bid_amount"] = str(bid_amount) if bid_strategy: params["bid_strategy"] = bid_strategy if bid_constraints: params["bid_constraints"] = json.dumps(bid_constraints) if bid_adjustments is not None: params["bid_adjustments"] = json.dumps(bid_adjustments) if start_time: params["start_time"] = start_time if end_time: params["end_time"] = end_time # Add DSA fields if provided (both required for EU-targeted ad sets) if dsa_beneficiary: params["dsa_beneficiary"] = dsa_beneficiary if dsa_payor: params["dsa_payor"] = dsa_payor # Add mobile app parameters if provided if promoted_object: params["promoted_object"] = json.dumps(promoted_object) if destination_type: params["destination_type"] = destination_type # Enable Dynamic Creative if requested if is_dynamic_creative is not None: params["is_dynamic_creative"] = "true" if bool(is_dynamic_creative) else "false" if frequency_control_specs is not None: params["frequency_control_specs"] = json.dumps(frequency_control_specs) if multi_advertiser_ads is not None: params["multi_advertiser_ads"] = str(multi_advertiser_ads) if regional_regulated_categories is not None: params["regional_regulated_categories"] = json.dumps(regional_regulated_categories) if regional_regulation_identities is not None: params["regional_regulation_identities"] = json.dumps(regional_regulation_identities) if attribution_spec is not None: params["attribution_spec"] = json.dumps(attribution_spec) try: data = await make_api_request(endpoint, access_token, params, method="POST") return json.dumps(data, indent=2) except Exception as e: error_msg = str(e) # Enhanced error handling for DSA beneficiary issues if "permission" in error_msg.lower() or "insufficient" in error_msg.lower(): return json.dumps({ "error": "Insufficient permissions to set DSA beneficiary. Please ensure you have business_management permissions.", "details": error_msg, "params_sent": params, "permission_required": True }, indent=2) elif "dsa_beneficiary" in error_msg.lower() and ("not supported" in error_msg.lower() or "parameter" in error_msg.lower()): return json.dumps({ "error": "DSA beneficiary parameter not supported in this API version. Please set DSA beneficiary manually in Facebook Ads Manager.", "details": error_msg, "params_sent": params, "manual_setup_required": True }, indent=2) elif "benefits from ads" in error_msg or "DSA beneficiary" in error_msg: return json.dumps({ "error": "DSA beneficiary required for European compliance. Please provide the person or organization that benefits from ads in this ad set.", "details": error_msg, "params_sent": params, "dsa_required": True }, indent=2) else: return json.dumps({ "error": "Failed to create ad set", "details": error_msg, "params_sent": params }, indent=2) - meta_ads_mcp/core/adsets.py:86-87 (registration)Registration of create_adset as an MCP tool via the @mcp_server.tool() decorator on line 86. The @meta_api_tool decorator on line 87 wraps it for authentication and error handling.
@mcp_server.tool() @meta_api_tool - meta_ads_mcp/core/api.py:100-283 (helper)The make_api_request function used by create_adset to make HTTP requests to Meta's Graph API. Handles GET/POST/PUT/DELETE methods, authentication, rate limit headers, and error responses.
async def make_api_request( endpoint: str, access_token: str, params: Optional[Dict[str, Any]] = None, method: str = "GET" ) -> Dict[str, Any]: """ Make a request to the Meta Graph API. Args: endpoint: API endpoint path (without base URL) access_token: Meta API access token params: Additional query parameters method: HTTP method (GET, POST, DELETE) Returns: API response as a dictionary """ # Validate access token before proceeding if not access_token: logger.error("API request attempted with blank access token") return { "error": { "message": "Authentication Required", "details": "A valid access token is required to access the Meta API", "action_required": "Please authenticate first" } } url = f"{META_GRAPH_API_BASE}/{endpoint}" headers = { "User-Agent": USER_AGENT, } request_params = params or {} request_params["access_token"] = access_token # Add appsecret_proof when META_APP_SECRET is configured. # Required for system user tokens and recommended by Meta for all # server-to-server API calls to verify token authenticity. # See: https://developers.facebook.com/docs/graph-api/securing-requests/ app_secret = os.environ.get("META_APP_SECRET", "") if app_secret and access_token: request_params["appsecret_proof"] = hmac.new( app_secret.encode("utf-8"), access_token.encode("utf-8"), hashlib.sha256, ).hexdigest() # Logging the request (masking token for security) masked_params = {k: "***MASKED***" if k in ("access_token", "appsecret_proof") else v for k, v in request_params.items()} logger.debug(f"API Request: {method} {url}") logger.debug(f"Request params: {masked_params}") # Check for app_id in params app_id = auth_manager.app_id logger.debug(f"Current app_id from auth_manager: {app_id}") async with httpx.AsyncClient() as client: try: if method == "GET": # For GET, JSON-encode dict/list params (e.g., targeting_spec) to proper strings encoded_params = {} for key, value in request_params.items(): if isinstance(value, (dict, list)): encoded_params[key] = json.dumps(value) else: encoded_params[key] = value response = await client.get(url, params=encoded_params, headers=headers, timeout=30.0) elif method == "POST": # For Meta API, POST requests need data, not JSON if 'targeting' in request_params and isinstance(request_params['targeting'], dict): # Convert targeting dict to string for the API request_params['targeting'] = json.dumps(request_params['targeting']) # Convert lists and dicts to JSON strings for key, value in request_params.items(): if isinstance(value, (list, dict)): request_params[key] = json.dumps(value) logger.debug(f"POST params (prepared): {masked_params}") response = await client.post(url, data=request_params, headers=headers, timeout=30.0) elif method == "PUT": # PUT for updates that Meta requires via PUT (e.g., creative_features_spec). # Meta expects access_token as a query param, not in the body. query_params = {} body_params = {} for key, value in request_params.items(): if key in ("access_token", "appsecret_proof"): query_params[key] = value elif isinstance(value, (list, dict)): body_params[key] = json.dumps(value) else: body_params[key] = value response = await client.put(url, params=query_params, data=body_params, headers=headers, timeout=30.0) elif method == "DELETE": response = await client.delete(url, params=request_params, headers=headers, timeout=30.0) else: raise ValueError(f"Unsupported HTTP method: {method}") response.raise_for_status() logger.debug(f"API Response status: {response.status_code}") # Log Meta rate limit headers for observability _log_meta_rate_limit_headers(response.headers, endpoint) # Ensure the response is JSON and return it as a dictionary try: return response.json() except json.JSONDecodeError: # If not JSON, return text content in a structured format return { "text_response": response.text, "status_code": response.status_code } except httpx.HTTPStatusError as e: error_info = {} try: error_info = e.response.json() except: error_info = {"status_code": e.response.status_code, "text": e.response.text} logger.error(f"HTTP Error: {e.response.status_code} - {error_info}") # Log Meta rate limit headers even on errors _log_meta_rate_limit_headers(e.response.headers, endpoint) # Check for rate limit errors vs authentication errors. # Code 4 is a rate limit (NOT auth) — do NOT invalidate token. if "error" in error_info: error_obj = error_info.get("error", {}) error_code = error_obj.get("code") if isinstance(error_obj, dict) else None if error_code == 4: # Application-level rate limit — token is still valid logger.warning( f"Facebook API rate limit (code=4, subcode={error_obj.get('error_subcode', 'N/A')}, " f"msg={error_obj.get('error_user_msg', error_obj.get('message', 'N/A'))}). " f"Token is still valid — NOT invalidating." ) elif error_code in [190, 102, 200, 10]: logger.warning(f"Detected Facebook API auth error: {error_code}") if error_code == 200 and "Provide valid app ID" in error_obj.get("message", ""): logger.error("Meta API authentication configuration issue") logger.error(f"Current app_id: {app_id}") return { "error": { "message": "Meta API authentication configuration issue. Please check your app credentials.", "original_error": error_obj.get("message"), "code": error_code } } auth_manager.invalidate_token() elif e.response.status_code in [401, 403]: logger.warning(f"Detected authentication error ({e.response.status_code})") auth_manager.invalidate_token() elif e.response.status_code in [401, 403]: logger.warning(f"Detected authentication error ({e.response.status_code})") auth_manager.invalidate_token() # Include full details for technical users full_response = { "headers": dict(e.response.headers), "status_code": e.response.status_code, "url": str(e.response.url), "reason": getattr(e.response, "reason_phrase", "Unknown reason"), "request_method": e.request.method, "request_url": str(e.request.url) } # Return a properly structured error object return { "error": { "message": f"HTTP Error: {e.response.status_code}", "details": error_info, "full_response": full_response } } except Exception as e: logger.error(f"Request Error: {str(e)}") return {"error": {"message": str(e)}} - meta_ads_mcp/core/api.py:287-431 (helper)The meta_api_tool decorator used by create_adset. Handles authentication token injection, error wrapping, and response serialization for all Meta API tools.
def meta_api_tool(func): """Decorator for Meta API tools that handles authentication and error handling.""" @functools.wraps(func) async def wrapper(*args, **kwargs): try: # Log function call logger.debug(f"Function call: {func.__name__}") logger.debug(f"Args: {args}") # Log kwargs without sensitive info safe_kwargs = {k: ('***TOKEN***' if k == 'access_token' else v) for k, v in kwargs.items()} logger.debug(f"Kwargs: {safe_kwargs}") # Log app ID information app_id = auth_manager.app_id logger.debug(f"Current app_id: {app_id}") logger.debug(f"META_APP_ID env var: {os.environ.get('META_APP_ID')}") # If access_token is not in kwargs or not kwargs['access_token'], try to get it from auth_manager if 'access_token' not in kwargs or not kwargs['access_token']: try: access_token = await auth.get_current_access_token() if access_token: kwargs['access_token'] = access_token logger.debug("Using access token from auth_manager") else: logger.warning("No access token available from auth_manager") # Add more details about why token might be missing if (auth_manager.app_id == "YOUR_META_APP_ID" or not auth_manager.app_id) and not auth_manager.use_pipeboard: logger.error("TOKEN VALIDATION FAILED: No valid app_id configured") logger.error("Please set META_APP_ID environment variable or configure in your code") elif auth_manager.use_pipeboard: logger.error("TOKEN VALIDATION FAILED: Pipeboard authentication enabled but no valid token available") logger.error("Complete authentication via Pipeboard service or check PIPEBOARD_API_TOKEN") else: logger.error("Check logs above for detailed token validation failures") except Exception as e: logger.error(f"Error getting access token: {str(e)}") # Add stack trace for better debugging import traceback logger.error(f"Stack trace: {traceback.format_exc()}") # Final validation - if we still don't have a valid token, return authentication required if 'access_token' not in kwargs or not kwargs['access_token']: logger.warning("No access token available, authentication needed") # Add more specific troubleshooting information auth_url = auth_manager.get_auth_url() app_id = auth_manager.app_id using_pipeboard = auth_manager.use_pipeboard logger.error("TOKEN VALIDATION SUMMARY:") logger.error(f"- Current app_id: '{app_id}'") logger.error(f"- Environment META_APP_ID: '{os.environ.get('META_APP_ID', 'Not set')}'") logger.error(f"- Pipeboard API token configured: {'Yes' if os.environ.get('PIPEBOARD_API_TOKEN') else 'No'}") logger.error(f"- Using Pipeboard authentication: {'Yes' if using_pipeboard else 'No'}") # Check for common configuration issues - but only if not using Pipeboard if not using_pipeboard and (app_id == "YOUR_META_APP_ID" or not app_id): logger.error("ISSUE DETECTED: No valid Meta App ID configured") logger.error("ACTION REQUIRED: Set META_APP_ID environment variable with a valid App ID") elif using_pipeboard: logger.error("ISSUE DETECTED: Pipeboard authentication configured but no valid token available") logger.error("ACTION REQUIRED: Complete authentication via Pipeboard service") # Provide different guidance based on authentication method if using_pipeboard: return json.dumps({ "error": { "message": "Pipeboard Authentication Required", "details": { "description": "Your Pipeboard API token is invalid or has expired", "action_required": "Update your Pipeboard token", "setup_url": "https://pipeboard.co/setup", "token_url": "https://pipeboard.co/api-tokens", "configuration_status": { "app_id_configured": bool(app_id) and app_id != "YOUR_META_APP_ID", "pipeboard_enabled": True, }, "troubleshooting": "Go to https://pipeboard.co/setup to verify your account setup, then visit https://pipeboard.co/api-tokens to obtain a new API token", "setup_link": "[Verify your Pipeboard account setup](https://pipeboard.co/setup)", "token_link": "[Get a new Pipeboard API token](https://pipeboard.co/api-tokens)" } } }, indent=2) else: return json.dumps({ "error": { "message": "Authentication Required", "details": { "description": "You need to authenticate with the Meta API before using this tool", "action_required": "Please authenticate first", "auth_url": auth_url, "configuration_status": { "app_id_configured": bool(app_id) and app_id != "YOUR_META_APP_ID", "pipeboard_enabled": False, }, "troubleshooting": "Check logs for TOKEN VALIDATION FAILED messages", "markdown_link": f"[Click here to authenticate with Meta Ads API]({auth_url})" } } }, indent=2) # Call the original function result = await func(*args, **kwargs) # If the result is a string (JSON), try to parse it to check for errors if isinstance(result, str): try: result_dict = json.loads(result) if "error" in result_dict: logger.error(f"Error in API response: {result_dict['error']}") # If this is an app ID error, log more details if isinstance(result_dict.get("details", {}).get("error", {}), dict): error_obj = result_dict["details"]["error"] if error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""): logger.error("Meta API authentication configuration issue") logger.error(f"Current app_id: {app_id}") # Replace the confusing error with a more user-friendly one return json.dumps({ "error": { "message": "Meta API Configuration Issue", "details": { "description": "Your Meta API app is not properly configured", "action_required": "Check your META_APP_ID environment variable", "current_app_id": app_id, "original_error": error_obj.get("message") } } }, indent=2) except Exception: # Not JSON or other parsing error, wrap it in a dictionary return json.dumps({"data": result}, indent=2) # If result is already a dictionary, ensure it's properly serialized if isinstance(result, dict): return json.dumps(result, indent=2) return result except McpToolError: raise # Let FastMCP set isError: true and refund the usage credit except Exception as e: logger.error(f"Error in {func.__name__}: {str(e)}") return json.dumps({"error": str(e)}, indent=2) return wrapper - Test suite for the CBO budget conflict detection logic inside create_adset. Validates that the function catches budget conflicts between campaign (CBO) and ad set levels before hitting Meta's API.
#!/usr/bin/env python3 """ Unit Tests for CBO Budget Conflict Detection in create_adset When a campaign uses Campaign Budget Optimization (CBO), the campaign itself holds a daily_budget or lifetime_budget. Meta rejects any create_adset request that also includes a budget at the ad-set level, returning a cryptic "Invalid parameter" error (subcode 1885621). This test suite validates that create_adset detects the conflict early, before hitting Meta's API, and returns a clear, actionable error message. Usage: uv run python -m pytest tests/test_cbo_budget_conflict.py -v """ import pytest import json from unittest.mock import AsyncMock, patch, call from meta_ads_mcp.core.adsets import create_adset def parse_result(result: str) -> dict: """ Parse the result from create_adset, handling the data wrapper. The meta_api_tool decorator wraps responses in {"data": "..."} format, where the inner value is a JSON string. This helper unwraps it. """ result_data = json.loads(result) if "data" in result_data and isinstance(result_data["data"], str): return json.loads(result_data["data"]) return result_data @pytest.fixture def basic_adset_params(): """Minimal valid adset parameters shared across tests.""" return { "account_id": "act_123456789", "campaign_id": "campaign_111222333", "name": "Test Ad Set", "optimization_goal": "LINK_CLICKS", "billing_event": "IMPRESSIONS", "targeting": { "age_min": 18, "age_max": 65, "geo_locations": {"countries": ["US"]}, }, "access_token": "test_token", } @pytest.fixture def cbo_campaign_response(): """API response simulating a CBO campaign with a daily_budget.""" return { "id": "campaign_111222333", "name": "My CBO Campaign", "bid_strategy": "LOWEST_COST_WITHOUT_CAP", "daily_budget": "10000", # $100 — indicates CBO mode } @pytest.fixture def cbo_campaign_lifetime_response(): """API response simulating a CBO campaign with a lifetime_budget.""" return { "id": "campaign_111222333", "name": "My CBO Lifetime Campaign", "bid_strategy": "LOWEST_COST_WITHOUT_CAP", "lifetime_budget": "500000", # $5000 — indicates CBO mode } @pytest.fixture def abo_campaign_response(): """API response simulating an ABO campaign (no campaign-level budget).""" return { "id": "campaign_111222333", "name": "My ABO Campaign", "bid_strategy": "LOWEST_COST_WITHOUT_CAP", # No daily_budget or lifetime_budget — ABO mode } @pytest.fixture def adset_created_response(): """Successful adset creation response.""" return { "id": "adset_999888777", "name": "Test Ad Set", } class TestCboBudgetConflict: """Tests for CBO budget conflict detection in create_adset.""" @pytest.mark.asyncio async def test_daily_budget_with_cbo_campaign_returns_error( self, basic_adset_params, cbo_campaign_response ): """ When daily_budget is provided and the campaign already has a daily_budget, create_adset should return a clear error before calling the create endpoint. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) assert "error" in result_data assert "Budget conflict" in result_data["error"] assert "CBO" in result_data["error"] or "CBO" in result_data.get("details", "") assert "fix" in result_data assert "daily_budget" in result_data["fix"] or "lifetime_budget" in result_data["fix"] # Should have made exactly ONE call (campaign check), not two (campaign + create) assert mock_api.call_count == 1 @pytest.mark.asyncio async def test_lifetime_budget_with_cbo_campaign_daily_budget_returns_error( self, basic_adset_params, cbo_campaign_response ): """ lifetime_budget on the ad set also conflicts with a CBO campaign. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, lifetime_budget=50000) result_data = parse_result(result) assert "error" in result_data assert "Budget conflict" in result_data["error"] assert mock_api.call_count == 1 @pytest.mark.asyncio async def test_lifetime_budget_with_cbo_campaign_lifetime_budget_returns_error( self, basic_adset_params, cbo_campaign_lifetime_response ): """ When the campaign has a lifetime_budget (CBO), providing lifetime_budget on the ad set should also be caught. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_lifetime_response result = await create_adset(**basic_adset_params, lifetime_budget=50000) result_data = parse_result(result) assert "error" in result_data assert "Budget conflict" in result_data["error"] assert mock_api.call_count == 1 @pytest.mark.asyncio async def test_daily_budget_with_abo_campaign_succeeds( self, basic_adset_params, abo_campaign_response, adset_created_response ): """ When the campaign has no budget (ABO mode), providing daily_budget on the ad set is valid and the create request should proceed normally. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: # First call: campaign check → ABO (no budget) # Second call: adset create → success mock_api.side_effect = [abo_campaign_response, adset_created_response] result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) assert "error" not in result_data assert result_data["id"] == "adset_999888777" # Campaign check + create = 2 calls assert mock_api.call_count == 2 # Verify daily_budget was sent in the create call create_call_params = mock_api.call_args_list[1][0][2] assert create_call_params["daily_budget"] == "5000" @pytest.mark.asyncio async def test_no_adset_budget_with_cbo_campaign_succeeds( self, basic_adset_params, cbo_campaign_response, adset_created_response ): """ When no budget is provided for the ad set (standard CBO usage), create_adset should proceed without any conflict check error. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: # First call: campaign check (needed for bid_amount check; no budget provided) # Second call: adset create mock_api.side_effect = [cbo_campaign_response, adset_created_response] result = await create_adset(**basic_adset_params) result_data = parse_result(result) assert "error" not in result_data assert result_data["id"] == "adset_999888777" @pytest.mark.asyncio async def test_error_includes_campaign_name( self, basic_adset_params, cbo_campaign_response ): """ The error message should include the campaign name to help the user identify which campaign triggered the conflict. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) # Campaign name should appear in the error assert "My CBO Campaign" in result_data["error"] @pytest.mark.asyncio async def test_error_includes_campaign_id( self, basic_adset_params, cbo_campaign_response ): """ The error message should include the campaign ID so the user can look it up. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) assert basic_adset_params["campaign_id"] in result_data["error"] @pytest.mark.asyncio async def test_error_includes_fix_instructions( self, basic_adset_params, cbo_campaign_response ): """ The error response should include a 'fix' field explaining how to resolve it. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) assert "fix" in result_data # The fix should mention removing budget fields fix_text = result_data["fix"] assert "daily_budget" in fix_text or "budget" in fix_text.lower() @pytest.mark.asyncio async def test_error_includes_alternative( self, basic_adset_params, cbo_campaign_response ): """ The error response should include an 'alternative' field for users who actually want ABO (ad set-level budgets). """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = cbo_campaign_response result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) assert "alternative" in result_data @pytest.mark.asyncio async def test_campaign_check_failure_falls_through_to_create( self, basic_adset_params, adset_created_response ): """ If the campaign pre-flight check itself fails (e.g., network error, permission), create_adset should fall through and attempt the create call normally. """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: # First call: campaign check fails # Second call: adset create succeeds mock_api.side_effect = [Exception("Network error"), adset_created_response] result = await create_adset(**basic_adset_params, daily_budget=5000) result_data = parse_result(result) # Should NOT return a budget conflict error — fell through to create assert "Budget conflict" not in result_data.get("error", "") assert result_data["id"] == "adset_999888777" # Both calls should have been made assert mock_api.call_count == 2 @pytest.mark.asyncio async def test_no_budget_no_bid_amount_with_campaign_bid_cap_strategy( self, basic_adset_params ): """ Regression: when no budget is provided but campaign uses LOWEST_COST_WITH_BID_CAP, the bid strategy check should still fire (original behavior preserved). """ with patch( "meta_ads_mcp.core.adsets.make_api_request", new_callable=AsyncMock ) as mock_api: mock_api.return_value = { "id": "campaign_111222333", "name": "Bid Cap Campaign", "bid_strategy": "LOWEST_COST_WITH_BID_CAP", # No budget — ABO or no budget } result = await create_adset(**basic_adset_params)