Skip to main content
Glama

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

TableJSON Schema
NameRequiredDescriptionDefault
account_idYes
campaign_idYes
nameYes
optimization_goalYes
billing_eventYes
statusNoPAUSED
daily_budgetNo
lifetime_budgetNo
targetingNo
bid_amountNo
bid_strategyNo
bid_constraintsNo
bid_adjustmentsNo
start_timeNo
end_timeNo
dsa_beneficiaryNo
dsa_payorNo
promoted_objectNo
destination_typeNo
is_dynamic_creativeNo
frequency_control_specsNo
multi_advertiser_adsNo
regional_regulated_categoriesNo
regional_regulation_identitiesNo
attribution_specNo
access_tokenNo

Output Schema

TableJSON Schema
NameRequiredDescriptionDefault
resultYes

Implementation Reference

  • 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)
  • 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
  • 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)}}
  • 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)
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so the description carries the burden. It includes some behavioral notes (e.g., CBO constraints, field immutability) but lacks an overall summary of side effects (e.g., cost implications, state changes). The parameter caveats add partial transparency, but missing a clear behavioral statement.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is very long and could be more concise. While the structure (parameter-by-parameter) is clear, the length may hinder quick scanning. A shorter summary of each parameter or more front-loaded content would improve conciseness.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the complexity (26 parameters, no output schema shown), the description is highly complete, covering each parameter thoroughly with edge cases, usage notes, and error conditions. It provides sufficient context for correct invocation, compensating for missing structured metadata.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

With 0% schema coverage, the description compensates excellently, providing detailed semantics for all 26 parameters including valid values, formats, examples, dependencies, and constraints. This far exceeds what the schema alone provides.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states 'Create a new ad set in a Meta Ads account,' specifying the verb and resource. However, it does not explicitly differentiate from siblings like 'update_adset' or 'create_ad', though the name implies creation. A more explicit distinction would raise the score.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance on when to use this tool versus alternatives. For example, there is no mention that 'update_adset' should be used for modifications. The description focuses only on parameter details, ignoring usage context.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pipeboard-co/meta-ads-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server