update_ad
Update an existing ad by modifying its name, status, or attaching an existing creative, with pre-write backup and post-update validation.
Instructions
Update an existing ad. Supervised write - validates before applying.
Takes a pre-write snapshot for rollback, validates the update payload, applies via Meta API, and verifies post-write state.
Note on creative_id: Swaps the creative attached to this ad. The new creative must already exist (created via create_multi_asset_ad or the Meta UI). This does NOT create a new creative - it re-points the ad to an existing one.
Args: ad_id: Ad ID to update. name: New ad name. Subject to naming enforcement. status: New status. Allowed: 'PAUSED', 'ACTIVE', 'ARCHIVED'. Activating requires confirmation-level validation. creative_id: ID of an existing creative to attach to this ad. Format: numeric string (e.g., '120239290442460377').
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| ad_id | Yes | ||
| name | No | ||
| status | No | ||
| creative_id | No |
Implementation Reference
- meta_ads_mcp/core/ads.py:940-1190 (handler)The `update_ad` function is the main handler for updating an existing ad. It validates status/creative_id inputs, takes a pre-write snapshot, enforces naming rules, runs validation, calls Meta API, verifies post-write state, and logs the mutation.
# --- Phase C.3: Ad update --- @mcp.tool() def update_ad( ad_id: str, name: Optional[str] = None, status: Optional[str] = None, creative_id: Optional[str] = None, ) -> dict: """ Update an existing ad. Supervised write - validates before applying. Takes a pre-write snapshot for rollback, validates the update payload, applies via Meta API, and verifies post-write state. Note on creative_id: Swaps the creative attached to this ad. The new creative must already exist (created via create_multi_asset_ad or the Meta UI). This does NOT create a new creative - it re-points the ad to an existing one. Args: ad_id: Ad ID to update. name: New ad name. Subject to naming enforcement. status: New status. Allowed: 'PAUSED', 'ACTIVE', 'ARCHIVED'. Activating requires confirmation-level validation. creative_id: ID of an existing creative to attach to this ad. Format: numeric string (e.g., '120239290442460377'). """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # --- At least one field must be provided --- if all(v is None for v in [name, status, creative_id]): return { "error": "No update fields provided. Specify at least one field to update.", "supported_fields": ["name", "status", "creative_id"], "blocked_at": "input_validation", } # --- Status validation --- allowed_statuses = ["PAUSED", "ACTIVE", "ARCHIVED"] if status is not None: status_upper = status.upper().strip() if status_upper not in allowed_statuses: return { "error": f"Invalid status '{status}'. Allowed: {allowed_statuses}", "blocked_at": "input_validation", } status = status_upper # --- Creative ID validation --- if creative_id is not None: creative_id = creative_id.strip() if not creative_id.isdigit(): return { "error": f"Invalid creative_id '{creative_id}'. Must be a numeric string.", "blocked_at": "input_validation", } # --- Step 0: Pre-write snapshot --- api_client._ensure_initialized() try: current = api_client.graph_get( f"/{ad_id}", fields=["id", "name", "status", "effective_status", "adset_id", "campaign_id", "creative", "tracking_specs", "account_id"], ) except MetaAPIError as e: return { "error": f"Cannot read ad {ad_id} for pre-update snapshot: {e}", "blocked_at": "pre_snapshot", } account_id = current.get("account_id", "") if account_id and not account_id.startswith("act_"): account_id = f"act_{account_id}" rollback_ref = f"update_ad_{ad_id}_{timestamp.replace(' ', '_').replace(':', '')}" # Extract current creative ID for rollback reference current_creative = current.get("creative", {}) current_creative_id = current_creative.get("id") if isinstance(current_creative, dict) else None # --- Step 1: Naming enforcement (if name is being updated) --- effective_name = None naming_result = None if name is not None: from meta_ads_mcp.engine.naming_gate import enforce_naming naming_result = enforce_naming( proposed_name=name, object_type="ad", naming_inputs=None, ) if naming_result["critical_block"]: return { "error": f"Naming enforcement BLOCKED: {naming_result.get('fix_suggestion', 'Invalid name')}", "naming_result": naming_result, "blocked_at": "naming_enforcement", } effective_name = naming_result["final_name"] or name # --- Step 2: Build update payload --- api_payload = {} if effective_name is not None: api_payload["name"] = effective_name if status is not None: api_payload["status"] = status if creative_id is not None: # Meta API expects creative as {"creative_id": "123"} api_payload["creative"] = _json.dumps({"creative_id": creative_id}) # --- Step 3: Pre-write validation --- from meta_ads_mcp.validators.runner import run_validation, ActionClass action_class = ActionClass.ACTIVATE if status == "ACTIVE" else ActionClass.MODIFY_ACTIVE validation_result = run_validation( action_class=action_class, target_account_id=account_id, target_object_type="ad", target_object_id=ad_id, payload=api_payload, safety_tier=3, ) validation_dict = validation_result.to_dict() if validation_result.verdict.value == "fail": return { "error": "Pre-write validation failed. Ad NOT updated.", "validation": validation_dict, "blocked_at": "pre_write_validation", } if validation_result.verdict.value == "requires_confirmation" and status == "ACTIVE": return { "status": "requires_confirmation", "message": "Activating an ad requires explicit confirmation. Review validation and re-submit.", "validation": validation_dict, "ad_id": ad_id, "current_status": current.get("status"), "requested_status": "ACTIVE", } # --- Step 4: API call - update ad --- from meta_ads_mcp.safety.rate_limiter import enforce_rate_gate rate_gate = enforce_rate_gate(ad_id, "write") if not rate_gate["allowed"]: return { "error": f"Rate limit gate BLOCKED: {rate_gate['block_reason']}", "blocked_at": "rate_limit_gate", "rate_state": rate_gate["state"], "usage_pct": rate_gate["usage_pct"], } try: result = api_client.graph_post( f"/{ad_id}", data=api_payload, ) except MetaAPIError as e: return { "error": f"Meta API error during ad update: {e}", "validation": validation_dict, "blocked_at": "api_call", "rollback_reference": rollback_ref, "pre_update_state": { "name": current.get("name"), "status": current.get("status"), "creative_id": current_creative_id, }, } # --- Step 5: Post-write verification --- verification = { "ad_id": ad_id, "fields_updated": list(api_payload.keys()), "mismatches": [], } try: updated = api_client.graph_get( f"/{ad_id}", fields=["id", "name", "status", "effective_status", "creative"], ) if effective_name is not None: actual_name = updated.get("name", "") if actual_name != effective_name: verification["mismatches"].append({ "field": "name", "expected": effective_name, "actual": actual_name, }) if status is not None: actual_status = updated.get("status", "") if actual_status != status: verification["mismatches"].append({ "field": "status", "expected": status, "actual": actual_status, }) if creative_id is not None: updated_creative = updated.get("creative", {}) actual_creative_id = updated_creative.get("id") if isinstance(updated_creative, dict) else None if actual_creative_id != creative_id: verification["mismatches"].append({ "field": "creative_id", "expected": creative_id, "actual": actual_creative_id, }) verification["post_update_status"] = updated.get("status") verification["post_update_effective_status"] = updated.get("effective_status") verification["verified"] = len(verification["mismatches"]) == 0 except MetaAPIError as e: verification["verification_error"] = str(e) verification["verified"] = False verification["note"] = "Ad was updated but post-verification read failed." # --- Step 6: Mutation log entry --- fields_summary = ", ".join(f"{k}={v}" for k, v in api_payload.items()) log_entry = ( f"### [{timestamp}] UPDATE ad\n" f"- **Ad ID:** {ad_id}\n" f"- **Account:** {account_id}\n" f"- **Fields:** {fields_summary}\n" f"- **Validation:** {validation_result.verdict.value}\n" f"- **Verification:** {'OK' if verification.get('verified') else 'MISMATCH'}\n" f"- **Rollback ref:** {rollback_ref}\n" f"- **Pre-update state:** name={current.get('name')}, status={current.get('status')}, " f"creative_id={current_creative_id}\n" ) return { "ad_id": ad_id, "updated_fields": list(api_payload.keys()), "validation": validation_dict, "verification": verification, "pre_update_state": { "name": current.get("name"), "status": current.get("status"), "effective_status": current.get("effective_status"), "creative_id": current_creative_id, "adset_id": current.get("adset_id"), "campaign_id": current.get("campaign_id"), }, "rollback_reference": rollback_ref, "mutation_log_entry": log_entry, "rate_limit_usage_pct": api_client.rate_limits.max_usage_pct, } - meta_ads_mcp/core/ads.py:942-1190 (handler)The @mcp.tool() decorator registers update_ad as an MCP tool in the FastMCP server. This registration happens via the module import in server.py.
@mcp.tool() def update_ad( ad_id: str, name: Optional[str] = None, status: Optional[str] = None, creative_id: Optional[str] = None, ) -> dict: """ Update an existing ad. Supervised write - validates before applying. Takes a pre-write snapshot for rollback, validates the update payload, applies via Meta API, and verifies post-write state. Note on creative_id: Swaps the creative attached to this ad. The new creative must already exist (created via create_multi_asset_ad or the Meta UI). This does NOT create a new creative - it re-points the ad to an existing one. Args: ad_id: Ad ID to update. name: New ad name. Subject to naming enforcement. status: New status. Allowed: 'PAUSED', 'ACTIVE', 'ARCHIVED'. Activating requires confirmation-level validation. creative_id: ID of an existing creative to attach to this ad. Format: numeric string (e.g., '120239290442460377'). """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # --- At least one field must be provided --- if all(v is None for v in [name, status, creative_id]): return { "error": "No update fields provided. Specify at least one field to update.", "supported_fields": ["name", "status", "creative_id"], "blocked_at": "input_validation", } # --- Status validation --- allowed_statuses = ["PAUSED", "ACTIVE", "ARCHIVED"] if status is not None: status_upper = status.upper().strip() if status_upper not in allowed_statuses: return { "error": f"Invalid status '{status}'. Allowed: {allowed_statuses}", "blocked_at": "input_validation", } status = status_upper # --- Creative ID validation --- if creative_id is not None: creative_id = creative_id.strip() if not creative_id.isdigit(): return { "error": f"Invalid creative_id '{creative_id}'. Must be a numeric string.", "blocked_at": "input_validation", } # --- Step 0: Pre-write snapshot --- api_client._ensure_initialized() try: current = api_client.graph_get( f"/{ad_id}", fields=["id", "name", "status", "effective_status", "adset_id", "campaign_id", "creative", "tracking_specs", "account_id"], ) except MetaAPIError as e: return { "error": f"Cannot read ad {ad_id} for pre-update snapshot: {e}", "blocked_at": "pre_snapshot", } account_id = current.get("account_id", "") if account_id and not account_id.startswith("act_"): account_id = f"act_{account_id}" rollback_ref = f"update_ad_{ad_id}_{timestamp.replace(' ', '_').replace(':', '')}" # Extract current creative ID for rollback reference current_creative = current.get("creative", {}) current_creative_id = current_creative.get("id") if isinstance(current_creative, dict) else None # --- Step 1: Naming enforcement (if name is being updated) --- effective_name = None naming_result = None if name is not None: from meta_ads_mcp.engine.naming_gate import enforce_naming naming_result = enforce_naming( proposed_name=name, object_type="ad", naming_inputs=None, ) if naming_result["critical_block"]: return { "error": f"Naming enforcement BLOCKED: {naming_result.get('fix_suggestion', 'Invalid name')}", "naming_result": naming_result, "blocked_at": "naming_enforcement", } effective_name = naming_result["final_name"] or name # --- Step 2: Build update payload --- api_payload = {} if effective_name is not None: api_payload["name"] = effective_name if status is not None: api_payload["status"] = status if creative_id is not None: # Meta API expects creative as {"creative_id": "123"} api_payload["creative"] = _json.dumps({"creative_id": creative_id}) # --- Step 3: Pre-write validation --- from meta_ads_mcp.validators.runner import run_validation, ActionClass action_class = ActionClass.ACTIVATE if status == "ACTIVE" else ActionClass.MODIFY_ACTIVE validation_result = run_validation( action_class=action_class, target_account_id=account_id, target_object_type="ad", target_object_id=ad_id, payload=api_payload, safety_tier=3, ) validation_dict = validation_result.to_dict() if validation_result.verdict.value == "fail": return { "error": "Pre-write validation failed. Ad NOT updated.", "validation": validation_dict, "blocked_at": "pre_write_validation", } if validation_result.verdict.value == "requires_confirmation" and status == "ACTIVE": return { "status": "requires_confirmation", "message": "Activating an ad requires explicit confirmation. Review validation and re-submit.", "validation": validation_dict, "ad_id": ad_id, "current_status": current.get("status"), "requested_status": "ACTIVE", } # --- Step 4: API call - update ad --- from meta_ads_mcp.safety.rate_limiter import enforce_rate_gate rate_gate = enforce_rate_gate(ad_id, "write") if not rate_gate["allowed"]: return { "error": f"Rate limit gate BLOCKED: {rate_gate['block_reason']}", "blocked_at": "rate_limit_gate", "rate_state": rate_gate["state"], "usage_pct": rate_gate["usage_pct"], } try: result = api_client.graph_post( f"/{ad_id}", data=api_payload, ) except MetaAPIError as e: return { "error": f"Meta API error during ad update: {e}", "validation": validation_dict, "blocked_at": "api_call", "rollback_reference": rollback_ref, "pre_update_state": { "name": current.get("name"), "status": current.get("status"), "creative_id": current_creative_id, }, } # --- Step 5: Post-write verification --- verification = { "ad_id": ad_id, "fields_updated": list(api_payload.keys()), "mismatches": [], } try: updated = api_client.graph_get( f"/{ad_id}", fields=["id", "name", "status", "effective_status", "creative"], ) if effective_name is not None: actual_name = updated.get("name", "") if actual_name != effective_name: verification["mismatches"].append({ "field": "name", "expected": effective_name, "actual": actual_name, }) if status is not None: actual_status = updated.get("status", "") if actual_status != status: verification["mismatches"].append({ "field": "status", "expected": status, "actual": actual_status, }) if creative_id is not None: updated_creative = updated.get("creative", {}) actual_creative_id = updated_creative.get("id") if isinstance(updated_creative, dict) else None if actual_creative_id != creative_id: verification["mismatches"].append({ "field": "creative_id", "expected": creative_id, "actual": actual_creative_id, }) verification["post_update_status"] = updated.get("status") verification["post_update_effective_status"] = updated.get("effective_status") verification["verified"] = len(verification["mismatches"]) == 0 except MetaAPIError as e: verification["verification_error"] = str(e) verification["verified"] = False verification["note"] = "Ad was updated but post-verification read failed." # --- Step 6: Mutation log entry --- fields_summary = ", ".join(f"{k}={v}" for k, v in api_payload.items()) log_entry = ( f"### [{timestamp}] UPDATE ad\n" f"- **Ad ID:** {ad_id}\n" f"- **Account:** {account_id}\n" f"- **Fields:** {fields_summary}\n" f"- **Validation:** {validation_result.verdict.value}\n" f"- **Verification:** {'OK' if verification.get('verified') else 'MISMATCH'}\n" f"- **Rollback ref:** {rollback_ref}\n" f"- **Pre-update state:** name={current.get('name')}, status={current.get('status')}, " f"creative_id={current_creative_id}\n" ) return { "ad_id": ad_id, "updated_fields": list(api_payload.keys()), "validation": validation_dict, "verification": verification, "pre_update_state": { "name": current.get("name"), "status": current.get("status"), "effective_status": current.get("effective_status"), "creative_id": current_creative_id, "adset_id": current.get("adset_id"), "campaign_id": current.get("campaign_id"), }, "rollback_reference": rollback_ref, "mutation_log_entry": log_entry, "rate_limit_usage_pct": api_client.rate_limits.max_usage_pct, } - meta_ads_mcp/server.py:28-28 (registration)server.py imports the ads module which triggers the @mcp.tool() decoration of update_ad, registering it as an MCP tool.
from meta_ads_mcp.core import ads # noqa: E402, F401 - meta_ads_mcp/core/ads.py:949-967 (schema)Input schema/parameters for update_ad: ad_id (required), name, status (PAUSED/ACTIVE/ARCHIVED), creative_id (numeric string).
""" Update an existing ad. Supervised write - validates before applying. Takes a pre-write snapshot for rollback, validates the update payload, applies via Meta API, and verifies post-write state. Note on creative_id: Swaps the creative attached to this ad. The new creative must already exist (created via create_multi_asset_ad or the Meta UI). This does NOT create a new creative - it re-points the ad to an existing one. Args: ad_id: Ad ID to update. name: New ad name. Subject to naming enforcement. status: New status. Allowed: 'PAUSED', 'ACTIVE', 'ARCHIVED'. Activating requires confirmation-level validation. creative_id: ID of an existing creative to attach to this ad. Format: numeric string (e.g., '120239290442460377'). """ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - Companion tool for updating creative names. Creatives are immutable for copy/content. When changing ad copy, create a new creative and use update_ad(creative_id=new_id) to swap it on the ad.
@mcp.tool() def update_ad_creative( creative_id: str, name: Optional[str] = None, ) -> dict: """ Update an existing ad creative's name. IMPORTANT: Meta API does NOT allow changing copy, headline, CTA, or link URL on existing creatives. Creative content is immutable after creation. To change ad copy: create a NEW creative with create_ad_creative, then swap it on the ad with update_ad(creative_id=new_creative_id). Args: creative_id: Creative ID to update. name: New creative name. """ if name is None or not name.strip(): return { "error": "name is required. Meta only allows updating creative name (not copy/headline/CTA).", "note": "To change ad copy, create a NEW creative with create_ad_creative, then swap via update_ad.", "blocked_at": "input_validation", } api_client._ensure_initialized() try: current = api_client.graph_get( f"/{creative_id}", fields=["id", "name"], ) except MetaAPIError as e: return {"error": f"Cannot read creative {creative_id}: {e}", "blocked_at": "pre_snapshot"} try: api_client.graph_post( f"/{creative_id}", data={"name": name.strip()}, ) except MetaAPIError as e: return {"error": f"Meta API error: {e}", "blocked_at": "api_call"} return { "creative_id": creative_id, "updated_name": name.strip(), "previous_name": current.get("name"), "note": "Only name updated. Creative content (copy, headline, CTA, image) is immutable. To change copy, create a new creative and swap via update_ad.", }