AdsAgent / MCP Guide

AdsAgent MCP Agent Onboarding

Cross-channel ad ops for Claude, Cursor, and other MCP clients start here. This public page mirrors the canonical onboarding markdown used by AdsAgent hosted MCP. When mcp.guide_version changes, reconnect the MCP server, reread the guide resources, and use this page as the human-readable companion.

Protocol

2026-07-28 + legacy

Guide Version

2026-07-30.1

Production MCP

https://adsagent.md/mcp/v2

Test MCP

https://t.adsagent.md/mcp/v2

> Generated by scripts/generate_mcp_contract_docs.py from the server's profile, catalog, OAuth-scope, policy, and result-budget registries. Guide version: 2026-07-30.1.

Public web guide: https://adsagent.md/docs/mcp-onboarding

0. Connection And Compatibility

  • Default: https://adsagent.md/mcp/v2 (v2, stateless JSON HTTP, 49152-byte response budget).
  • Legacy fallback: https://adsagent.md/mcp (legacy_v1, stateful HTTP, dotted aliases, 262144-byte response budget).
  • Test endpoints: https://t.adsagent.md/mcp/v2 with https://t.adsagent.md/mcp as the legacy fallback.
  • Header: Authorization: Bearer <mcp_token>.
  • /mcp/v2 and /mcp select AdsAgent Meta product profiles; they are not MCP protocol-version identifiers.
  • Let the client negotiate the protocol. MCP 2026-07-28 uses stateless server/discover; supported legacy revisions (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) continue to use initialize.
  • Prefer the v2 product profile for new Claude, Codex, and other MCP connections. Use /mcp only when the client cannot use that product profile. Existing /mcp clients remain compatible.
  • Keep the same endpoint and bearer across a protocol upgrade. MCP protocol changes alone never require re-registration or token replacement.
  • Never synthesize MCP-Protocol-Version or Mcp-Session-Id; session recovery applies only after a legacy protocol was negotiated.
  • After a server update, close and reopen the transport, re-list tools, read adsagent://guide/brief, and rerun setup_get_status.

Reading order: adsagent://guide/brief -> adsagent://guide/catalog -> one bounded adsagent://guide/catalog/<domain> topic. adsagent://guide/tools is a large fallback reference and must not be read end-to-end during onboarding.

1. OAuth Safe Mode Capability Contract

New OAuth grants default to the least-privilege mcp scope. Existing legacy grants retain their callable surface. The server selects policy; the agent cannot self-elevate.

| OAuth scope | Effective capabilities | Added to baseline | | --- | --- | --- | | mcp | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh | Baseline | | mcp.assets.refresh | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh | Baseline | | mcp.insights.pull | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh, mcp.insights.pull | mcp.insights.pull | | mcp.optimize.write | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh, mcp.optimize.write | mcp.optimize.write | | mcp.routing.write | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh, mcp.routing.write | mcp.routing.write | | mcp.operator | mcp.read, mcp.templates.write, mcp.launch.prepare, mcp.launch.confirm, mcp.assets.refresh, mcp.operator | mcp.operator |

mcp.operator is an explicit override for bounded operator-only tool families, but OAuth responses still hide internal IDs and never expose insights_query_rows. Public handles are product_ref, task_ref, owner_ref, external ad_account_id, exact template names, and creative selection keys.

Internal task_id is operator-only. OAuth workflows always use the returned task_ref.

Before any Meta status, budget, or bid change, inspect setup_get_status.capabilities.delivery_mutations. If allowed=false, stop: for permission_action=enable_in_dashboard, direct the user to /dashboard/settings#mcp-access; for permission_action=reauthorize_with_scope, ask the user to reconnect with OAuth scope mcp.optimize.write. Never change customer permissions automatically. After the user changes access, reconnect the MCP transport and re-list tools. For ABO use overview_update_adset_budget then overview_update_confirm; for CBO use overview_update_campaign_budget then overview_update_campaign_budget_confirm. Never substitute one budget level for the other. For one known campaign, ad set, or ad, do not scan historical Insights to discover its current status, budget, or bid. Call the matching prepare tool directly: prepare reads the live current value and does not mutate Meta. If an explicit preflight read is needed, call overview_get_live_configs with exactly one typed entity and no mutation_ref. management=true or product/date Insights is not live configuration evidence.

2. First Run

  1. Call setup_get_status.

Inspect top-level client_skill_pack as notify-only policy. If the installed version is unknown, continue silently; never auto-update.

  1. Call products_list; it is already sorted by recent spend.
  2. Tell the user the product-card count and ask for one product/account plus date_from and date_to.
  3. If setup_get_status.capabilities.agent_method_profile.profile_id=adsagent_agent_methods_v1, call insights_query_consistent once with root query_contract_version=1 and one scope or up to 20 scopes.
  4. Poll every distinct returned task_ref serially with tasks_get_status(response_mode=compact). When terminal, consume its bounded result directly only if task status=completed, result.status=complete, and result.meta.complete=true; do not rerun page 1 merely to continue. Stop on a missing/incomplete result or partial_completed, failed, or cancelled. For a next page, preserve every filter and increment only page. Set min_as_of to the task result.meta.source_observed_at, or for an immediate complete response use the corresponding scope overview's query_contract.coverage.source_observed_at. Locate a single-scope overview at data, and each ordered multi-scope overview at data.items[].result; with multiple scopes use the earliest first-page source anchor. Preserve each overview's meta.inventory_anchor and pass inventory_anchors on page 2+ as one ordered opaque value per original scope. If continuation_valid=false or an anchor is rejected, discard all partial rows and restart page 1 serially with the original query.
  5. If agent_method_profile is absent, preserve native behavior: one scope -> insights_query_overview; multiple scopes -> one insights_query_batch_overview. Pass metadata_contract_version=1; use native consistency only when advertised.
  6. Report server totals only when completeness is proven. A missing scope is unknown, never zero.

3. Facebook, Google Ads, And TikTok Connect

  • Meta: use setup_begin_facebook_connect, let the human open the returned URL, then poll setup_check_facebook_connect.
  • Google Ads and TikTok: use connections_create_intent, let the human complete the single-use browser flow, then poll connections_check_intent.
  • Never collect platform passwords, cookies, OAuth codes, or bearer tokens in chat.

4. Insights And Output Stability

  • Never fan out one overview call per product/account. On mcp_fanout_detected, stop the loop and follow data.recommended_tool plus data.recommended_mode for all current and pending scopes.
  • Profile mode is opt-in only through query_contract_version=1 at the request root. Keep scope and scopes mutually exclusive, preserve input order, and stop on verification_pending, rate_limited, data_not_fresh, incomplete_coverage, failed, or complete=false.
  • Use require_fresh only when setup_get_status.capabilities.insights_consistency advertises it. Otherwise use one cached tenant-ledger query; explicit cumulative cached windows may cover up to 365 days. Never turn one long read into repeated 31-day refresh calls. An advertised source refresh must obey capabilities.insights_refresh.max_date_range_days.
  • Candidate filtering stays server-side: keep page_size<=50, use allowlisted filters combined with AND, and retry a corrected request once when adsagent_query_invalid names a public invalid field. Text hierarchy fields support contains/prefix/eq; metrics, budgets, and bids support gt/gte/lt/lte/eq; status and delivery enums support eq/in. Legacy search and spend_gt remain compatible, but do not use dedupe_by in new workflows. Use effective_status for Meta delivery outcomes and configured_status for configured ACTIVE/PAUSED state. With group_by=ad, use returned ad_account_id, ad_account_name, campaign_id, campaign_name, adset_id, adset_name, ad_id, and ad_name; do not prefetch or fan out parents. When the user explicitly asks for every matching Ad, preserve each ad_id and advance page serially while data.meta.has_more=true; keep all filters identical and pin min_as_of to the first-page source anchor described above. Deduplicate Ad names, classify language, and perform business grouping in the client. Never parallelize pages or enlarge the page. For an explicitly requested large full table, call grouped insights_export_csv with the same filters and consume the artifact instead of putting thousands of rows in chat. On scope_unavailable, do not infer another workspace/token or Meta permission, a specific missing account, a disabled-account gap, missing date coverage, stale ledger data, or zero spend from that signal alone. Run setup_get_status and the matching discovery tool (products_list or accounts_list_linked_accounts) once. If the same scope remains listed, retry the identical bounded read once; if it persists, stop and preserve support_ref for operator review. Never broaden scope or change permissions.
  • Read setup_get_status.support_reporting before reporting. Submit automatically only when automatic_allowed=true; in manual mode submit only after explicit user request or approval; when off or unavailable, preserve the support_ref and stop. For an eligible unresolved internal/contract/dependency/verification error, call support_report_error once using the request wrapper, a stable idempotency_key, bounded classifications, and client versions. Never submit prompts, raw arguments, tokens, cookies, authorization, provider responses, or confirmation material. Reporting is observational only and never retries, confirms, resumes, or replays a write. Never report an error returned by either support tool; stop and retain its support_ref. Preserve expected validation/auth/permission/business-rule failures without auto-reporting unless repeated. Poll support_get_report_status no earlier than poll_after_seconds or when the user asks.
  • Do not borrow profile behavior from another channel and do not synthesize query_contract, completeness, timezone, or freshness in the client. Absence of agent_method_profile means use the channel's native fallback.
  • freshness_kind=age_only means only that a pull is recent; it is not mutation coverage. source_watermark, metrics_observed_after_mutation, and config_verified_live are distinct proofs.
  • Do not make a decision when the response is verification_pending / data_not_fresh, the launch date is unknown, or complete=false.
  • Reference campaigns by ID, not name. Report effective Meta statuses verbatim.
  • Use creatives_list(created_from=<inclusive>, created_to=<exclusive>, limit<=50) with timezone-aware ISO 8601 bounds to discover one recent upload window. Follow next_cursor serially with unchanged filters while meta.has_more=true. Omit both bounds for the whole library; never infer upload completion from cache age alone.
  • Follow adsagent://guide/metadata-contract: configured ACTIVE/PAUSED is configured_status; provider delivery, review, and rejection outcomes are effective_status; additive delivery_status plus delivery_issues expose normalized inherited blockers without rewriting child state. For campaign/adset/ad entity-existence totals, require meta.inventory_coverage=complete and zero_insights_entities_included=true; partial, insights_rows_only, and unavailable keep absence unknown. Inventory freshness is independent from Insights freshness. Legacy read status mirrors effective_status. Money values are account-currency major units, budget_level is campaign|adset, and bid_strategy/optimization_goal are canonical lower-case values.
  • Return metric summaries as horizontal Markdown tables. Do not sum visible rows or infer totals from page length.
  • Use insights_export_csv only when explicitly requested. If queued, poll tasks_get_status with the returned task_ref until terminal=true. Compact and standard terminal responses include result.artifact; HTTP GET its opaque download_url byte-for-byte and never redact, rebuild, decode, or truncate it. Return artifact metadata/link and never raw CSV.
  • Oversized normal results are bounded previews. Full tables and files use artifact metadata instead of consuming the agent's context window.

Meta structured filter fields

| group_by | Text fields | Numeric fields | Enum fields | | --- | --- | --- | --- | | account | ad_account_id, ad_account_name | clicks, conversion_value, conversions, cpa, cpc, cpm, ctr, impressions, reach, roas, spend | account_blocker_code, currency, delivery_issue_codes, delivery_status | | campaign | ad_account_id, ad_account_name, campaign_id, campaign_name | bid_amount, clicks, conversion_value, conversions, cpa, cpc, cpm, ctr, daily_budget, impressions, lifetime_budget, reach, roas, spend | account_blocker_code, bid_strategy, budget_level, configured_status, currency, delivery_issue_codes, delivery_status, effective_status, objective, product_type, status | | adset | ad_account_id, ad_account_name, adset_id, adset_name, app_id, campaign_id, campaign_name, pixel_id | bid_amount, clicks, conversion_value, conversions, cpa, cpc, cpm, ctr, daily_budget, impressions, lifetime_budget, reach, roas, spend | account_blocker_code, bid_strategy, billing_event, budget_level, configured_status, conversion_event, currency, delivery_issue_codes, delivery_status, effective_status, objective, optimization_goal, product_type, status | | ad | ad_account_id, ad_account_name, ad_id, ad_name, adset_id, adset_name, app_id, campaign_id, campaign_name, pixel_id | bid_amount, clicks, conversion_value, conversions, cpa, cpc, cpm, ctr, daily_budget, impressions, lifetime_budget, reach, roas, spend | account_blocker_code, bid_strategy, billing_event, budget_level, configured_status, conversion_event, currency, delivery_issue_codes, delivery_status, effective_status, objective, optimization_goal, product_type, status |

All conditions are AND. Text operators: contains, prefix, eq; numeric operators: gt, gte, lt, lte, eq; enum operators: eq, in. At most 12 conditions and 20 values per in condition.

effective_status is Meta's actual delivery/review outcome, including DISAPPROVED and PENDING_REVIEW. configured_status is the configured ACTIVE/PAUSED state. Legacy status remains an alias of effective_status.

Meta metadata contract

json
{
  "canonical_values": {
    "bid_strategy": "platform_agnostic_lowercase",
    "billing_event": "meta_native_uppercase",
    "budget_level": [
      "campaign",
      "adset"
    ],
    "conversion_event": "lowercase_separate_from_optimization_goal",
    "currency": "uppercase_three_letter_account_currency",
    "delivery_status": {
      "configured": "configured_status",
      "effective": "effective_status",
      "write_target": "target_configured_status"
    },
    "money_unit": "major",
    "normalized_delivery_block_state": {
      "field": "delivery_status",
      "values": [
        "BLOCKED",
        "PENDING",
        "UNKNOWN"
      ]
    },
    "objective": "meta_native_uppercase",
    "optimization_goal": "platform_agnostic_lowercase"
  },
  "public_metadata_contract_version": 1,
  "resource": "adsagent://guide/metadata-contract",
  "rules": {
    "activation": "Pass metadata_contract_version=1 to native Insights/live-config tools, or query_contract_version=1 to insights_query_consistent.",
    "conflicts": "Canonical and legacy write values that differ are rejected.",
    "delivery_block_boundary": "delivery_status is an additive normalized block state. It never replaces configured_status, effective_status, or write targets.",
    "inventory_boundary": "For campaign, adset, and ad reads, inventory_coverage=complete means the current entity inventory was left-joined before filtering and pagination, including zero-Insights entities. partial, insights_rows_only, and unavailable never prove entity absence or delivery health. Read inventory_freshness and inventory_source_observed_at separately from Insights freshness.",
    "legacy_scope": "Aliases are accepted only at the exact listed scope.",
    "new_clients": "Use canonical fields and canonical values only.",
    "no_global_aliases": true,
    "product_scope_boundary": "A product scope includes current cached asset bindings as well as accounts with Insights rows in the requested window. An account with no window Insights remains in coverage; missing metrics are unknown and must make a required-complete read incomplete rather than silently shrinking the product scope.",
    "read_write_boundary": "effective_status is an observed delivery outcome; target_configured_status is a requested configured state.",
    "source_boundary": "Insights ledger metadata and live Meta configuration are distinct evidence sources and must remain labeled."
  }
}

Queued response shape:

json
{
  "status": "queued",
  "task_ref": "<opaque public ref>",
  "poll": {
    "tool": "tasks_get_status",
    "arguments": {"task_ref": "<opaque public ref>"}
  }
}
python
poll = resp["poll"]
while True:
    task = call_tool(poll["tool"], poll["arguments"])
    if task["terminal"]:
        break

5. Consequential Actions

For a task error meta_write_verification_pending or meta_write_verification_ambiguous, call operations_get_context(task_ref=...) and never repeat the write. Follow each returned failure's retry flags; only manual_new_task_allowed=true permits preparing a new task with fresh approval and a new confirm token.

Launches and delivery changes use prepare -> sanitized approval_request.summary -> explicit user approval -> confirm. Status mutations use target_configured_status=ACTIVE|PAUSED; never pass an effective_status outcome as a write target. Confirm tokens are exact, single-use values with a 15-minute TTL. Never reconstruct, retry, or automatically confirm them. If a token expires, prepare again, show the new summary, and obtain fresh approval. Use deny to discard.

After templates_create or templates_update, call templates_get with the exact template_name. For reverse-engineered imports, pass source_adset_id and optional source_account_id without configuration maps; the server re-reads and persists the source snapshot. If a legacy client supplies all three complete configuration maps, those maps are preserved and reverse-engineered source provenance is cleared. A manual complete-map template may retain source_account_id without source_adset_id; this is an account binding, not proof of a Meta source snapshot. Partial source-plus-map requests are rejected. Treat templates_list as bounded screening only: when its migration_state.persisted=false, perform the exact-name detail read and repair or re-save if detail is not launch-ready. Continue to QuickCreate prepare only when template_snapshot.persisted=true, template_snapshot.normalization.rejected_paths_complete=true, template_snapshot.configuration_coverage.complete=true, and template_snapshot.launch_readiness.status=ready_for_prepare. template_snapshot.launch_critical_coverage describes saved coverage; runtime fields may still be supplied by the QuickCreate request and are validated after overrides by prepare. legacy_projection and quarantined templates remain readable but block prepare until repaired or re-saved. Preserve template_snapshot_ref and template_snapshot_revision from the approval summary. If confirm returns template_snapshot_changed, do not reuse the old confirm token: read the template again, prepare again, show the new summary, and obtain fresh explicit approval. Never infer snapshot identity from updated_at.

Notification channels and Meta Ads webhooks use notifications_integrations_list -> notifications_integration_prepare -> sanitized summary -> explicit user approval -> notifications_integration_confirm. Credential inputs are encrypted in the approval draft and never returned by list or confirm. test_channel sends exactly one external message. Meta registration/subscription actions never change FB user permissions. If Meta returns an uncertain subscription outcome, re-list the integration state and never replay the consumed confirm token.

Notification monitoring contract

notifications_integrations_list.monitoring_capabilities is the machine-readable source of truth.

Meta Ads webhook monitoring is event-driven and requires one subscription for each ad account:

| Meta field | Notification type | Severity | | --- | --- | --- | | ad_recommendations | meta_ad_recommendation | info | | creative_fatigue | meta_creative_fatigue | warning | | effective_status | meta_effective_status | status_dependent | | in_process_ad_objects | meta_ad_object_processed | info | | subscriptions | meta_ads_subscription | info | | with_issues_ad_objects | meta_ad_object_issue | critical |

Cached asset-health monitoring runs after an asset refresh or notifications_scan; that scan does not call Meta directly:

| Notification type | Condition | Severity | | --- | --- | --- | | ad_account_status | account_status_not_active | warning_or_critical | | ad_account_recharge | spend_cap_remaining_below_threshold | warning_or_critical | | page_unpublished | page_is_not_published | critical | | page_ads_restricted | page_is_not_promotion_eligible | critical | | page_no_advertise_access | no_connected_fb_user_has_advertise_access | warning | | fb_user_abnormal | fb_user_connection_not_active | warning_or_critical | | fb_user_disabled | create_pull_and_decision_are_all_disabled | warning | | fb_user_token_expiring | user_token_expires_within_threshold | warning |

Default thresholds: spend-cap remaining <= 50.0 account-currency major units or <= 10%; USER-token expiry <= 7 days; duplicate alert cooldown 3600 seconds. Product ownership is included when a saved product maps to the affected asset.

Delivery channels are Email, Feishu, and Telegram, filterable by exact notification_type (or all) and minimum severity. Webhooks do not stream spend/balance metrics and do not replace Insights pulls. Ad-account balance, Page restrictions, and FB User health come from the cached asset-health scan. effective_status events are live-read before AdsAgent emits the verified notification.

When a prepare returns category=launch_request_incomplete, code=adsagent_request_incomplete, and public invalid_fields, correct only those advertised fields and rerun the prepare tool once. Prepare creates no Meta object. If that one correction fails, the rule is redacted/unknown, or operator_review_required is returned, stop and preserve support_ref for operator handoff. Never reuse or automatically retry a confirm token or consequential write.

When confirm first returns code=mcp_meta_quota_deferred, recover only if request_sent=false, safe_to_retry=true, and operator_review_required=false. The server has already exhausted its bounded pre-send admission retries and did not send the Graph POST. Treat plan_recovery.action=stop_remaining_mutations as a circuit breaker: stop the entire mutation loop immediately and call no later confirm during the blocked window. Preserve completed_mutations, receipts, and support_refs, place only the current item in not_sent_mutations, leave later items in remaining_mutations, and set safe_resume_from to the current item. Honor the largest applicable retry_after_seconds plus jitter. After the window, re-prepare only unchanged not-sent and remaining entity/value pairs, show one fresh consolidated approval summary, and resume serially. Never repeat a completed mutation or reuse an old confirm token. Any sent or uncertain write must use operation recovery and must not be replayed.

QuickCreate prepare checks the final Page, Pixel, FB User status, Create switches, and Token before issuing approval, and the worker repeats the check before execution. On no_create_permission, direct the user to /dashboard/assets/fb-users to enable Create on an active connection, then prepare again. Never change customer permissions automatically.

QuickCreate modes stay server-owned. Use creative_source.media_mode=multi for a frozen 2-10 item media window, creative_source.mode=carousel for ordered 2-10 image cards per group, interest_pack_mode=random for prepare-time per-ad-set Audience Pack assignment, and append mode only with its matching target id. Never fan out or re-randomize these choices client-side, and never replace an append target after prepare.

QuickCreate requires destination.type=web|app. Web requires web_url and forbids app_id, store_url, and deep_link. App requires at least one of app_id, store_url, or deep_link and forbids web_url. Follow the machine-readable creation contract; do not infer destination type from a template or URL.

QuickCreate Append supports exactly append_mode=append-campaign with target_campaign_id, or append_mode=append-adset with target_adset_id. append-adset requires execution.campaign_count=1 and execution.adset_count=1 and inherits the existing parent budget. Never send append_mode=existing, existing_campaign_id, existing_adset_id, or product_ref. On adsagent_request_incomplete with public invalid_fields, correct only those fields and rerun prepare once; show the new approval summary and obtain fresh explicit approval before confirm. Never auto-confirm, change permissions, rotate accounts, or replay a confirm token.

After a delivery confirm, call the returned next_action exactly. The expected tool is overview_get_live_configs with typed entities plus mutation_ref; retry only while verification is pending. Verification is pinned to the FB user that performed the write; if that subject is unavailable, keep pending/not_available and never substitute another token or stored Insights. Use operations_get(mutation_ref) for a delivery-mutation receipt. For a task error meta_write_verification_pending or meta_write_verification_ambiguous, use operations_get_context(task_ref=...) and never repeat the write. Follow each returned failure's retry flags; only manual_new_task_allowed=true permits preparing a new task with fresh approval and a new confirm token. Use insights_query_consistent(consistency=require_fresh, after_mutation_ref=mutation_ref) only for post-write metrics: metrics_observed_after_mutation does not verify delivery configuration.

For a terminal create/copy task, inspect result.create_reconciliation before result.failures. Require reconciled=true before claiming every requested object is accounted for. Use creative_results to map each ad_name and available selection_key/selection_keys to its created ad_id. Treat configuration.source=approved_task_payload and live_verified=false as approved execution input, not live Meta configuration. A failed auxiliary adimages receipt with workflow_status=recovered_by_url_fallback has no final-Ad impact and never permits retry or a new task. operations_get_context(response_mode=compact) returns receipt totals, reconciliation, and anomalous receipts; request standard only when every receipt is needed. Report each bounded result.failures.items[] entry with its public ad_name, code, message, and next_action; raw Meta errors remain private. Never retry the unchanged write. If operator_review_required=true or unclassified_count>0, report the known items, stop, and preserve the task/support reference for operator review. A corrected manual retry must be a newly prepared task with fresh user approval.

Bulk Ad writes may be split into configurable, sequential AdsAgent chunks. This is a defensive reliability policy, not proof of a fixed Meta batch limit. Preserve acknowledged objects and receipts across chunks. Only an item with manual_new_task_allowed=true may enter a newly prepared task with fresh user approval; pending or ambiguous items must remain in operation recovery.

Copy routing:

  • One source ad -> copy_ad_quick_copy.
  • Campaign or ad set -> copy_ad_clone_structure.
  • Repeat a previous task -> campaigns_recreate_from_task.
  • Ask deep-post reuse versus fresh creative upload; never guess.
  • Partnership/boosted-post sources require deep post reuse. Fresh copy is rejected during prepare before approval; show the bounded warning and do not retry with fresh.
  • Copy contract v3 sets creation_contract_version=3 and explicit request_mode=single|grouped. Single mode uses source_ad_id, source_ad_account_id, target_ad_account_id, campaign_count, adset_count, ads_per_adset, and target_campaign_name. Grouped mode uses grouped_plan.campaigns[].campaign_name, campaigns[].adsets[].adset_name, and campaigns[].adsets[].ads[].source_ad_id; optional destination Ad names use ads[].ad_name. Bare nested name remains compatible only at the exact object path and conflicting values are rejected. Read adsagent://guide/name-contract; flattened Insights names, source snapshots, naming patterns, selectors, and destination names are distinct roles. The grouped tree supports 1-1-N, 1-N-1, custom layouts, deep/fresh, and same/cross-account copies as one paused-by-default resumable task. The first ad of the first ad set seeds campaign settings, and the first ad of every ad set seeds that ad set's settings; order them deliberately and verify each settings_source_ad_id in the approval summary. Show the complete summary, obtain one explicit approval, then pass its cgb_confirm_* token unchanged to copy_ad_quick_copy_confirm.
  • Use countries_override for explicit included countries. Use worldwide_override=true plus excluded_countries_override for worldwide-minus-country targeting, and read the frozen geo_targeting_override back before approval.

Machine-readable creation model and read-to-write mapping:

json
{
  "adapter_mappings": {
    "destination.app_id": "meta.promoted_object.application_id",
    "destination.deep_link": "meta.creative.app_link",
    "destination.store_url": "meta.promoted_object.object_store_url",
    "destination.web_url": "meta.creative.link_url"
  },
  "creation_contract_version": 3,
  "grouped_copy_canonical_example": {
    "creation_contract_version": 3,
    "grouped_plan": {
      "ad_status": "PAUSED",
      "adset_status": "PAUSED",
      "campaign_status": "PAUSED",
      "campaigns": [
        {
          "adsets": [
            {
              "ads": [
                {
                  "ad_name": "<optional target ad name>",
                  "source_ad_id": "<source ad id>"
                }
              ],
              "adset_name": "<optional ad set name>"
            }
          ],
          "campaign_name": "<campaign name>"
        }
      ],
      "copy_mode": "deep",
      "source_ad_account_id": "<source ad account id>",
      "target_ad_account_id": "<target ad account id>"
    },
    "request_mode": "grouped"
  },
  "grouped_plan_canonical_example": {
    "ad_status": "PAUSED",
    "adset_status": "PAUSED",
    "campaign_status": "PAUSED",
    "campaigns": [
      {
        "adsets": [
          {
            "ads": [
              {
                "ad_name": "<optional target ad name>",
                "source_ad_id": "<source ad id>"
              }
            ],
            "adset_name": "<optional ad set name>"
          }
        ],
        "campaign_name": "<campaign name>"
      }
    ],
    "copy_mode": "deep",
    "source_ad_account_id": "<source ad account id>",
    "target_ad_account_id": "<target ad account id>"
  },
  "path_scoped_fields": [
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "request_mode",
      "conflict_policy": "must_match_single_or_grouped_payload_shape",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(single,grouped)"
    },
    {
      "accepted_legacy_aliases": [
        "ad_id"
      ],
      "canonical_field": "source_ad_id",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "campaign_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[]",
      "sensitive_classification": "business_text",
      "value_type": "non_blank_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "adset_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[]",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "ad_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[].ads[]",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "ad_id"
      ],
      "canonical_field": "source_ad_id",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[].ads[]",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "camp_num",
        "num_campaigns"
      ],
      "canonical_field": "campaign_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "adgroup_count",
        "adset_num",
        "num_adsets"
      ],
      "canonical_field": "adset_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "ad_num",
        "num_ads_per_adset"
      ],
      "canonical_field": "ads_per_adset",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count_per_parent"
    },
    {
      "accepted_legacy_aliases": [
        "ad_account_id"
      ],
      "canonical_field": "source_ad_account_id",
      "conflict_policy": "reject_if_values_differ_after_act_prefix_normalization",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_account_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "target_account_id"
      ],
      "canonical_field": "target_ad_account_id",
      "conflict_policy": "reject_if_values_differ_after_act_prefix_normalization",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_account_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "campaign_name_override",
        "campaign_name"
      ],
      "canonical_field": "target_campaign_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "country_override"
      ],
      "canonical_field": "countries_override",
      "conflict_policy": "canonical_array_wins_only_when_legacy_is_absent_or_equal",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_targeting",
      "value_type": "iso_3166_1_alpha_2_array"
    },
    {
      "accepted_legacy_aliases": [
        "adgroup_count"
      ],
      "canonical_field": "adset_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "budget_level"
      ],
      "canonical_field": "budget.level",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(campaign,adset)"
    },
    {
      "accepted_legacy_aliases": [
        "budget_type"
      ],
      "canonical_field": "budget.type",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(daily,lifetime)"
    },
    {
      "accepted_legacy_aliases": [
        "budget_amount"
      ],
      "canonical_field": "budget.amount",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "business_value",
      "value_type": "positive_money_major_units"
    },
    {
      "accepted_legacy_aliases": [
        "bid_strategy"
      ],
      "canonical_field": "bid.strategy",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "bid_strategy"
    },
    {
      "accepted_legacy_aliases": [
        "bid_cap"
      ],
      "canonical_field": "bid.cap",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "business_value",
      "value_type": "optional_positive_money_major_units"
    },
    {
      "accepted_legacy_aliases": [
        "campaign_status"
      ],
      "canonical_field": "statuses.campaign",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [
        "adset_status"
      ],
      "canonical_field": "statuses.adset",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [
        "ad_status"
      ],
      "canonical_field": "statuses.ad",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "destination.type",
      "conflict_policy": "web_app_fields",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(web,app)"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "destination.web_url",
      "conflict_policy": "distinct_from_store_url_and_deep_link",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "http_url"
    },
    {
      "accepted_legacy_aliases": [
        "overrides.store_url"
      ],
      "canonical_field": "destination.store_url",
      "conflict_policy": "distinct_from_web_url_and_deep_link",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "http_url"
    },
    {
      "accepted_legacy_aliases": [
        "overrides.deeplink"
      ],
      "canonical_field": "destination.deep_link",
      "conflict_policy": "distinct_from_web_url_and_store_url",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "application_deep_link"
    },
    {
      "accepted_legacy_aliases": [
        "creative_names"
      ],
      "canonical_field": "creative_source.creative_names",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_text",
      "value_type": "creative_display_name_array"
    },
    {
      "accepted_legacy_aliases": [
        "partnership_rows"
      ],
      "canonical_field": "creative_source.partnership_rows",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_payload",
      "value_type": "partnership_row_array"
    },
    {
      "accepted_legacy_aliases": [
        "carousel_groups"
      ],
      "canonical_field": "creative_source.carousel_groups",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_payload",
      "value_type": "carousel_group_array"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "ad_name",
      "conflict_policy": "reject_if_both_present",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.creative_source.carousel_groups[]",
      "sensitive_classification": "business_text",
      "value_type": "non_blank_display_name"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "app_id",
      "conflict_policy": "adapter_maps_to_meta_application_id",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.destination",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_application_identifier"
    }
  ],
  "quick_create_canonical_example": {
    "ad_account_id": "<target ad account id>",
    "creation_contract_version": 3,
    "creative_source": {
      "creative_names": [
        "<creative name>"
      ],
      "mode": "creative"
    },
    "destination": {
      "app_id": null,
      "deep_link": null,
      "store_url": null,
      "type": "web",
      "web_url": "https://example.com"
    },
    "execution": {
      "adset_count": 1,
      "bid": {
        "cap": null,
        "strategy": "lowest_cost"
      },
      "budget": {
        "amount": 50,
        "level": "adset",
        "type": "daily"
      },
      "campaign_count": 1,
      "statuses": {
        "ad": "PAUSED",
        "adset": "PAUSED",
        "campaign": "PAUSED"
      }
    },
    "template_name": "<template name>"
  },
  "read_to_write_mappings": {
    "reuse_source_ad_name_as_target_default": {
      "read_field": "insights.ad_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].adsets[].ads[].ad_name"
    },
    "reuse_source_adset_name_as_target_default": {
      "read_field": "insights.adset_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].adsets[].adset_name"
    },
    "reuse_source_campaign_name_as_target_default": {
      "read_field": "insights.campaign_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].campaign_name"
    },
    "select_insights_ad_as_copy_source": {
      "read_field": "insights.ad_id",
      "relation": "selected_source_reference",
      "write_field": "grouped_plan.campaigns[].adsets[].ads[].source_ad_id"
    }
  },
  "single_copy_canonical_example": {
    "ads_per_adset": 1,
    "adset_count": 1,
    "campaign_count": 1,
    "copy_mode": "deep",
    "creation_contract_version": 3,
    "request_mode": "single",
    "source_ad_account_id": "<optional source ad account id>",
    "source_ad_id": "<source ad id>",
    "target_ad_account_id": "<optional target ad account id>",
    "target_campaign_name": "<optional target campaign name>"
  }
}

6. MMP Reads

Use product-scoped MMP tools and pass channel_pid. Long supported windows are split by the server. For channel_pid=all, status=partial and complete=false mean at least one channel failed; report failed channels instead of silently returning a smaller total.

7B. B2B Agent Output Contract

Answer

Lead with the direct answer and server-computed totals. Use cleaned / aggregated results.

Scope

State product/account, date range, grouping, attribution, and completeness.

Results

Use a compact horizontal Markdown table. Do not use raw-row reads for normal business questions, and do not return only item counts when totals or summaries are available. Preserve effective statuses and public IDs.

Notes

Mention partial/missing scopes, artifact links, or retry state. Do not volunteer optimization advice unless requested.

7C. Optional Client Enhancements

AdsAgent Tri-Channel Skill Pack (Claude Code, Codex CLI, and other Agent-Skills clients)

This optional skill pack does not replace MCP. Do not pause or block hosted MCP setup for installation. After the MCP servers are configured and setup_get_status succeeds, present it as a post-setup reminder for Claude Code users, not a prerequisite. Do not ask the user for permission to install the skill pack before completing MCP setup. Skip this section for Grok, Notion, Cursor, and clients without plugin support. If uncertain whether plugins are supported, complete MCP setup first.

Claude commands:

bash
claude plugin marketplace add adsagents/adsagent-ai-skills
claude plugin install adsagent-ai-skills@adsagent-ai-skills
claude plugin update --scope user adsagent-ai-skills@adsagent-ai-skills
claude plugin uninstall --scope local adsagent-ai-skills@adsagent-ai-skills

Versions below v0.7.0 are stale. adsagent-meta-ai-skills remains a compatibility alias. Install at both local and user scope only when the client explicitly supports those scopes.

Codex compatibility path:

bash
git clone [email protected]:adsagents/adsagent-ai-skills.git ~/.codex/skills/adsagent-ai-skills

The pack contains /adsagent-router, /meta-insights, /meta-copy, /adsagent-notifications, /google-ads-insights, and /tiktok-insights. Suggested first test in a fresh Claude Code session: list connected Meta products, Google Ads customers, and TikTok advertisers, then ask which scope's today data to inspect. Never use a hardcoded example account, product, or advertiser name.

9. MCP Client Reliability Contract (REQUIRED for onboarding pass)

Passing the MCP onboarding check requires a client-side retry/backoff implementation:

  1. The client MUST bound per-token concurrency to 4-6; it MUST NOT launch unbounded parallel calls.
  2. The client MUST use batch tools for multiple scopes.
  3. The client MUST honor Retry-After plus jitter and retry serially.
  4. Cancellation MUST release local leases and stop retries.
  5. The client MUST retry read-only/idempotent calls only; it MUST never automatically retry a confirm token or consequential write. The strict mcp_meta_quota_deferred pre-send contract is a plan-level circuit breaker: stop before later confirms, wait, and re-prepare only the unchanged remainder under one fresh consolidated approval, never the old confirm.
  6. The client MUST poll queued tasks with task_ref until terminal=true.
  7. The client MUST treat profile top-level complete=false, native meta.complete=false, missing scopes, partial channels, verification_pending, and data_not_fresh as incomplete data.
  8. The client MUST NOT treat age-only freshness or an immediate write response as mutation verification.

9.2 Onboarding acceptance criteria

  • Launch 20 parallel requests on the same token and prove local concurrency stays bounded while the server returns structured 429 for excess work.
  • Simulate an MCP restart on legacy /mcp, discard the stale session, call initialize again, and retry one read.
  • Prove Retry-After is honored before retry.
  • Cache initialize/tools-list once per transport and refresh after a guide-version bump.

9.3 Reference implementation

python
MAX_CONCURRENCY = 6  # recommended range 4-6

def recover(response, pending_scopes):
    data = response.get("data") or (response.get("error") or {}).get("data") or {}
    subcode = data.get("subcode")
    if subcode == "mcp_session_not_found":
        assert data.get("action") == "discard_session_and_initialize"
        return "reinitialize"
    if subcode == "mcp_concurrency_limited":
        return f"wait {response.headers['Retry-After']} + jitter"
    if subcode == "mcp_fanout_detected":
        return call_tool("insights_query_batch_overview", pending_scopes)
    if response.get("error") == "dependency_unavailable":
        return f"wait {response.headers['Retry-After']} + jitter"
    return "do_not_retry"

9.4 Why this is required

The 2026-05-12 slim incident combined unbounded client concurrency, repeated initialize/tools-list calls, and immediate retries. The result was avoidable saturation and 503s. This contract prevents that failure mode as customer volume grows.

10. Client recovery contracts (404 / 429 / 503)

10.1 Session-not-found (404)

For legacy /mcp, branch on structured fields, never message text:

json
{"error": {"data": {"subcode": "mcp_session_not_found", "action": "discard_session_and_initialize"}}}

The action requires a transport-level reconnect, fresh initialize, fresh session ID, tool re-list, and one retry of the original read. Do not reuse the stale session ID. V2 is stateless and avoids this recovery path.

10.2 Concurrency caps (429) - two-layer

"mcp_concurrency_limited" is a transient admission signal. The per-token trigger is more than 8 simultaneous calls; the per-customer trigger is more than 20 simultaneous calls across tokens. Response header: Retry-After: 5. Wait plus jitter and retry one read at a time. More than one outstanding retry refills the same slots and is non-compliant.

10.2A Fan-out guard (429)

mcp_fanout_detected is a query-planning correction, not a transient retry. Stop the single-scope loop and follow data.recommended_tool plus data.recommended_mode for all pending scopes. The server recommends canonical insights_query_consistent scopes when the Agent Method Profile is live and native insights_query_batch_overview otherwise.

10.3 Full status-code branching table

| Status | Signal | Action | | --- | --- | --- | | 400/422 | invalid or missing input | Ask for the listed fields; never guess. | | 401/403 | "invalid_token" or scope failure | Read /.well-known/oauth-protected-resource, reconnect, or request the named scope; do not retry-loop. | | 404 | "mcp_session_not_found" on legacy | Run discard_session_and_initialize and retry one read once. | | 409/410 | stale/used confirm token | Re-prepare only after the user still wants the action. | | tool error | mcp_meta_quota_deferred + request_sent=false + safe_to_retry=true | Stop the entire plan before later confirms; preserve completed/current/later partitions; honor the largest Retry-After plus jitter; re-prepare only the unchanged remainder under one fresh consolidated approval. | | 429 | "mcp_concurrency_limited" / "mcp_fanout_detected" | Honor backoff, or follow the server-recommended tool/mode for fan-out. | | 503 | "dependency_unavailable" / server_overloaded | Honor backoff and retry one idempotent read. |

10.8 Client retry/backoff reference implementation

The parser handles both top-level data and JSON-RPC error.data:

python
def retry_after_seconds(payload, headers=None):
    headers = {str(k).lower(): v for k, v in (headers or {}).items()}
    candidates = [headers.get("retry-after")]
    if isinstance(payload, dict):
        candidates += [payload.get("retry_after"), payload.get("retry_after_seconds")]
        for node in (payload.get("data"), (payload.get("error") or {}).get("data")):
            if isinstance(node, dict):
                candidates += [node.get("retry_after"), node.get("retry_after_seconds")]
    for value in candidates:
        try:
            seconds = float(value)
        except (TypeError, ValueError):
            continue
        if seconds > 0:
            return seconds
    return None

Never print or persist bearer tokens, cookies, authorization headers, raw request bodies, or internal IDs in retry/audit logs.

MCP token identity & central-auth (Google Ads / TikTok)

The same central AdsAgent identity can route to Meta, Google Ads, and TikTok hosted MCP servers. Each server keeps its own tool catalog and platform authorization; a valid central token does not prove the linked ad-platform account is connected.