Skip to content

Blaze: add MCP-exposed prepare-campaign write ability (ADS-953)#48349

Open
j6ll wants to merge 10 commits intoadd/ads-952-blaze-mcp-abilityfrom
add/ads-953-blaze-create-campaign-ability
Open

Blaze: add MCP-exposed prepare-campaign write ability (ADS-953)#48349
j6ll wants to merge 10 commits intoadd/ads-952-blaze-mcp-abilityfrom
add/ads-953-blaze-create-campaign-ability

Conversation

@j6ll
Copy link
Copy Markdown
Member

@j6ll j6ll commented Apr 28, 2026

Stacked on top of #48325 (Phase 1). Re-base to trunk once that lands.

Summary

Phase 2 of ADS-953. Adds a blaze-ads/prepare-campaign write ability that lets an MCP client prepare a Blaze campaign proposal on the merchant's behalf.

This ability deliberately does not write to the DSP. It derives sensible defaults from the target post (title → ad heading, excerpt → snippet, featured image, permalink) and optional caller input (natural-language goal, budget, duration, copy, image, and audience overrides), bundles the result into a structured blaze_prefill payload, and returns a deep-link the merchant clicks to land in the existing Blaze widget with every field already populated. The merchant reviews, accepts payment / T&C, and submits from inside the widget — that's where the actual DSP write happens, through the existing flow.

The Blaze widget's prefill-from-URL behaviour is a separate piece of work in dsp-client; until that lands the merchant can still follow the link and edit manually.

How it works

  • blaze-ads/prepare-campaign ability registered with input/output schema, opted into Woo's MCP via woocommerce_mcp_include_ability, and gated behind a blaze_abilities_prepare_campaign_enabled kill-switch (default true) so we can disable centrally without a release.
  • Minimal public input. Only target_urn is required. budget_total, duration_days, goal, revision_instruction, copy/image overrides, and language/country audience overrides are optional. The raw DSP objective is not exposed as MCP input.
  • Shared safety check applied to every write action. Any ability marked meta.annotations.readonly => false runs through a TOS / Blaze-eligibility check (via Blaze::site_supports_blaze()) before its execute callback. Future Phase 3 write abilities inherit the safety layer automatically — adding a slug to OWNED_ABILITY_SLUGS is the only step needed.
  • Why a registration-time wrapper, not permission_callback or wp_before_execute_ability? The Abilities API strips WP_Error::data from permission_callback returns (security stance) and the before/after action hooks are fire-and-forget so they can't gate the call. The wp_register_ability_args filter is the only API-level injection point that lets a guardrail return a structured WP_Error and short-circuit the call.
  • Audit log of every write-path call (ability name, input, caller, result) via wp_after_execute_ability, scoped to our slugs.
  • MCP WP_Error constraint: the Woo MCP adapter strips WP_Error::data when forwarding errors to MCP clients (only message and code survive). Deep-links are embedded in the error message text so they reach Claude / other MCP clients; the data field is kept too as belt-and-braces for direct REST callers.

What's deferred (designed to plug into the same wrapper)

  • ADS-989 — per-session spend ceiling + Picard moderation passthrough.
  • ADS-988 — hybrid chat-native preview UX (in-chat image cards + approve / reject abilities). Phase 2 ships the deep-link approval flow, which is enough for launch.

Test plan

Prereqs: a JN site with WooCommerce 10.7+, MCP Integration feature toggled on, Jetpack with this branch active and connected, a user with manage_woocommerce, and at least one published post on the site.

Two surfaces, two auth schemes:

1. WP Abilities REST endpoint (Application Password):

curl -u "$USER:$APP_PASS" \
  -X POST -H 'Content-Type: application/json' \
  -d '{"input":{"target_urn":"urn:wpcom:post:<blog_id>:<post_id>"}}' \
  "$SITE/wp-json/wp-abilities/v1/abilities/blaze-ads/prepare-campaign/run"

Expected: 200 with { status: "pending_merchant_review", prefill_url: "...?blaze_prefill=...", prefill: {...}, message: "..." }.

2. Woo MCP server (REST API key + MCP client):

tools/list includes blaze-ads-prepare-campaign. tools/call with the same minimal input returns the same payload.

Checklist

  • Happy path: response includes prefill_url, prefill payload with post-derived defaults, and pending_merchant_review status.
  • Minimal input: target_urn alone prepares a proposal with server-owned defaults.
  • Caller overrides win: passing site_name / text_snippet / cta_text / image / language / country overrides beats post-derived defaults.
  • Public schema exposes no raw DSP objective input.
  • Invalid target_urn returns a 400 WP_Error with code blaze_invalid_target_urn.
  • Missing post returns a 404 WP_Error with code blaze_post_not_found.
  • No-excerpt fallback: post with empty excerpt produces a stripped/truncated content snippet rather than empty text_snippet.
  • Permission gate: caller without manage_woocommerce → 403. Disconnected Jetpack user → 403.
  • Site not Blaze-eligible: response is a WP_Error with the Blaze setup deep-link in the message text (and the same URL in the data field for non-MCP REST callers).
  • Kill-switch: add_filter( 'blaze_abilities_prepare_campaign_enabled', '__return_false' ) removes the ability from the registry; subsequent MCP tools/list no longer includes it.
  • Audit log entry written to error_log on each invocation, with ability slug, user_id, site_id, input, is_error.

Linear: ADS-953

Adds `blaze-ads/create-campaign` to the existing Blaze_Abilities class,
opted into Woo's MCP server alongside the read-only list-campaigns
ability shipped in ADS-952.

Architecture:

- Cross-cutting guardrails (TOS / payment check, per-session spend
  ceiling) live in a registration-time wrapper applied via the
  `wp_register_ability_args` filter to any ability marked
  `meta.annotations.readonly => false`. Future Phase 3 write abilities
  inherit them automatically.

- Audit log via `wp_after_execute_ability` listener, filtered to our
  write-path slugs.

- Kill-switch via `blaze_abilities_create_campaign_enabled` filter
  (default true).

- Successful response includes a draft campaign reference plus a
  deep-link to the Blaze widget for merchant approval. Phase 2
  deliberately keeps approval in-browser; chat-native preview is
  tracked as ADS-988.

Stubs to fill in: TOS / payment check, spend ceiling enforcement,
Picard moderation (pending intent decision), integration tests.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack or WordPress.com Site Helper), and enable the add/ads-953-blaze-create-campaign-ability branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack add/ads-953-blaze-create-campaign-ability
bin/jetpack-downloader test jetpack-mu-wpcom-plugin add/ads-953-blaze-create-campaign-ability

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • 🔴 Add testing instructions.
  • 🔴 Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


🔴 Action required: Please include detailed testing steps, explaining how to test your change, like so:

## Testing instructions:

* Go to '..'
*

🔴 Action required: We would recommend that you add a section to the PR description to specify whether this PR includes any changes to data or privacy, like so:

## Does this pull request change what data or activity we track or use?

My PR adds *x* and *y*.

Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Apr 28, 2026
@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented Apr 28, 2026

Code Coverage Summary

No summary data is available for parent commit 4824b1b, so cannot calculate coverage changes. 😴

If that commit is a feature branch rather than a trunk commit, this is expected. Otherwise, this should be updated once coverage for 4824b1b is available.

Full summary · PHP report · JS report

j6ll added 3 commits April 28, 2026 17:38
Reading vendor/wordpress/mcp-adapter ToolsHandler.php confirms it only
forwards WP_Error->message and ->code to MCP clients — the data field
is dropped. So the original spec's "deep-link in WP_Error data" pattern
won't reach Claude / other MCP clients via the MCP route.

Update the stub example to embed the deep-link as a URL inside the
error message text, so MCP clients can present it to the merchant. Keep
the data field too as belt-and-braces for direct WP Abilities REST
callers (standard WP REST serialization preserves it).

Code change is doc-only (the stub still returns true). The real
implementation will follow this pattern when wired up.
The wrapper now enforces a TOS / Blaze-eligibility gate using the
existing Blaze::site_supports_blaze() helper (proxies the WPCOM
/sites/<id>/blaze/status endpoint with a day-long transient cache, so
per-call cost is a transient lookup). When the site isn't yet eligible
the wrapper returns a WP_Error with the deep-link embedded in the
message text (so Claude / other MCP clients pass it through to the
merchant) and also in the data field as belt-and-braces for direct
WP Abilities REST callers.

Removes the check_spend_ceiling() stub. The "session" semantics for
spend ceiling and the gate-vs-log-only call for Picard moderation are
both real product decisions that don't need to block hack-month
delivery — split out into ADS-989 as a post-RSM follow-up. The wrapper
keeps a clearly-marked insertion point where ADS-989 will plug in.
Covers:

- Wrapper passes through unowned and read-only abilities unchanged
- Wrapper no-ops when execute_callback is missing
- Wrapper replaces execute_callback for owned write abilities
- Wrapped callback delegates to original when TOS / Blaze-eligibility passes
- Wrapped callback short-circuits with WP_Error when site is not eligible
- Deep-link URL is embedded in WP_Error message text (the Woo MCP adapter
  strips WP_Error::data, so this is the only way the URL reaches MCP clients)
- Deep-link URL also lives in the data field as belt-and-braces for direct
  REST callers
- Kill-switch via blaze_abilities_create_campaign_enabled removes
  create-campaign from registration without affecting list-campaigns
- opt_into_woo_mcp toggles ON for both owned slugs and leaves foreign
  slugs at their default

Inheritance is documented inline rather than dynamically tested — adding
a new write slug to OWNED_ABILITY_SLUGS is the only step a Phase 3
ability needs to take to inherit the wrapper.

Tests follow the WorDBless + transient-pre-population pattern; no Brain
Monkey or runtime mocking needed since site eligibility is cached via
transient and the wrapper is a static method we can call directly.
@j6ll j6ll marked this pull request as ready for review April 29, 2026 13:31
j6ll added 6 commits May 5, 2026 14:51
…rite)

Phase 2 v1 deliberately drops the direct DSP write from the agent
path. The ability now derives sensible defaults from the target post
+ the caller's input, bundles them into a structured prefill payload,
and returns a deep-link the merchant clicks to land in the existing
Blaze widget with every field already populated. The actual DSP write
happens when the merchant submits via the widget — we trust that flow.

Why this shape:

- The full DSP creation contract is 13 fields, several of which (payment
  method, T&C acceptance, exact dates, etc.) are merchant-context the
  AI can't reasonably fill in. Forcing an MCP-side draft would either
  fail at DSP validation or land an incomplete campaign on the merchant's
  account.
- The Blaze widget already renders the proper preview, audience, and
  approval UX. Reusing it is much faster than rebuilding any of that
  inside chat.
- The widget's prefill-from-URL behaviour is a separate piece of work
  in dsp-client that this PR doesn't ship; until that lands the
  merchant can still follow the link and edit manually.

Behaviour:

- Validates target_urn, resolves the post, and returns clean WP_Errors
  for invalid URN (400) or missing post (404).
- Builds prefill payload with: target_urn, type, site_name (post title
  or override), text_snippet (post excerpt, content fallback, or
  override), target_url, main_image (featured image), budget (mode +
  amount + currency from Woo or USD), duration_days, is_evergreen
  (default true), objective (default VIEWS).
- Builds prefill_url with the payload base64url-encoded in a
  blaze_prefill query param, inserted before the SPA hash so it
  reaches the widget bootstrap.
- Returns { status: "pending_merchant_review", message, prefill_url,
  prefill }. The message embeds the URL verbatim so MCP clients that
  strip structured fields still surface the link to the merchant.

Tests cover: invalid URN, missing post, happy-path payload + URL
shape, caller overrides winning over post-derived defaults, content
fallback when no excerpt is set.
Two prefill defaults that the widget needed to render a complete form
without any post-prefill clobbering on the client side:

- `cta_text`: defaults to "Shop Now" for products, "Learn More" for
  posts/pages. The widget treats CTA as a required field; without a
  default the merchant lands on a form with a validation error
  immediately after the agent's deep-link.
- `currency`: always USD. The DSP only bills in USD, so reading the
  Woo store currency just produced a misleading number for non-USD
  merchants — the DSP would treat the amount as USD regardless. Until
  the DSP gains multi-currency support, normalize at the prefill edge.

🤖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two new optional inputs the agent can pass through to narrow targeting:

- `languages`: array of ISO 639-1 codes (lowercased on the way through).
- `countries`: array of ISO 3166-1 alpha-2 codes (uppercased; codes
  that aren't 2 chars are dropped on the floor).

Empty arrays are stripped before they hit the payload so the widget
keeps its "all languages / worldwide" defaults instead of being forced
into an empty selection.

Schema-prose work (steering agents toward sensible defaults when the
user is vague) is intentionally out of scope here and tracked as a
Phase 2 follow-on.

🤖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@j6ll j6ll changed the title Blaze: add MCP-exposed create-campaign write ability (ADS-953) Blaze: add MCP-exposed prepare-campaign write ability (ADS-953) May 6, 2026
@j6ll
Copy link
Copy Markdown
Member Author

j6ll commented May 6, 2026

MCP/JN smoke test passed.

Validated through the Woo MCP adapter on the JN site:

  • Product lookup found Vintage Logo Tee / product ID 14.
  • blaze-ads/prepare-campaign prepared a campaign proposal and returned a Blaze review URL with blaze_prefill.
  • Follow-up revision worked with more sales-focused copy, UK targeting (GB), and a higher budget ($150 over 7 days).
  • Screenshot evidence shows the review page opening with the prepared values populated: product image, revised copy, Shop Now CTA, $150 weekly budget, estimated weekly clicks, payment/review step, and submit button.

No campaign was submitted by the MCP tool; it correctly stopped at merchant review/approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Blaze [Status] In Progress [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant