Highlights
- Meta bidding controls.
bid_strategy,bid_constraints
(roas_average_floor),promoted_object, ad-set-levelbid_amounton
update, andis_adset_budget_sharing_enabledare now exposed on the
Meta campaign / ad-set write tools — cost-cap, bid-cap and min-ROAS
strategies are reachable from mureo for the first time. - Meta targeting discovery. Two new read-only tools,
meta_ads_targeting_search(interest catalogue by keyword) and
meta_ads_targeting_categories(behavior / demographic / life-event
catalogues), resolve the internal targeting IDs an ad-settargeting
spec needs. - System-user token auth for Live Meta apps. The configure UI now
opens with an explicit either/or connection-method chooser and offers a
first-class "Paste a system-user token" path, closing the dead end
where a Live-mode Meta app can never complete browser OAuth against the
localhost redirect. The token is validated (granted vs required scopes,
reachable ad accounts) before it is saved, and is stored without
app_id/app_secretso it never enters the 53-day refresh path. - New tools:
meta_ads_pixels_createandmeta_ads_pages_list.
Create a Meta Pixel on an ad account, and list the Facebook Pages the
current token can manage (personal + business-owned). - Bid-cap guardrails.
StrategyPolicyGategains
max_bid_amount_per_ad_setandmax_cpc_bid_per_ad_group, and plugin
tools can now declare where they carry their proposed bid so those caps
reach a custom argument vocabulary too.
⚠️ Breaking change
Every builtin MCP tool (all 203) now declares additionalProperties: false
on its top-level inputSchema. An unknown or misspelled parameter used to
pass validation and then get silently dropped by the handler's whitelist —
a typo like budgett became a no-op. Such a call now fails validation
with a clear error naming the offending key. If you have callers that
relied on typo-tolerance or on passing extra keys, fix them before
upgrading. Nested object properties (e.g. Meta's targeting blob) are
intentionally left open so they stay forward-compatible with the platform
Graph / GAQL sub-object surfaces.
Key fixes
meta_ads_images_upload_filereturned 400 for every file. The
/adimagesmultipart upload was a dead end; the uploader now uses the
base64bytesform body, and Meta'serror_subcode/fbtrace_idare
surfaced instead of httpx's generic 400.- Ad-account selection in the Meta token card is genuinely optional.
The picker silently pre-selected the first account, and the route then
rejected an explicitnullasaccount_not_accessible(str(None)is
the truthy string"None"), so the optional path was unreachable.
Added
-
Meta system-user token in the configure UI. The Meta Ads authentication
step now offers a first-class "Paste a system-user token" option next to
Login with Facebook, closing the gap where Live-mode Meta apps cannot
complete OAuth from the localhost configure page (Facebook rejects the
localhost redirect on its own consent screen) and dev-mode apps cannot create
ad creatives (Marketing API subcode1885183). A new probe
(validate_meta_access_token) reports the token's granted vs required scopes
and the ad accounts it can reach; the operator validates, picks an account,
and saves viaPOST /api/credentials/meta/token. A never-expiring system-user
token is stored withoutapp_id/app_secretso it never enters the
53-day auto-refresh path. The Facebook-login timeout branch now points Meta
users at this option instead of silently resetting (#458). -
Plugin bid declarations. A plugin tool can now declare where it carries
its proposed bid in standard MCP metadata
(_meta={"mureo": {"bid": {"cpc_bid": "<key>", "unit": "micros"}}}), so the
built-inStrategyPolicyGateenforcesmax_bid_amount_per_ad_set/
max_cpc_bid_per_ad_groupon a plugin whose argument vocabulary differs from
the built-in Meta/Google keys — closing the bid twin of the silent-
underenforcement gapBudgetDeclarationclosed for budgets (#414). The
declaration names one or both channels (bid_amount, minor units, direct;
cpc_bid, currency units, ÷1e6 whenunit: micros), replaces the built-in
bid scan for that tool, and feeds the same_bid_inputsfail-closed choke
point — a present-but-unreadable declared key (inf/nan/bool/non-numeric/
nested) denies rather than sails through. Purely additive and opt-in:
undeclared tools keep today's behavior byte-identical. -
Bid-cap guardrails in
StrategyPolicyGate. The built-in gate now
enforces two new## Guardrailsrules alongside the budget caps:
max_bid_amount_per_ad_setrefuses ameta_ads_ad_sets_create/
meta_ads_ad_sets_updatewhosebid_amount(account-currency minor units,
identical to Meta's field) exceeds the cap, andmax_cpc_bid_per_ad_group
refuses agoogle_ads_ad_groups_create/google_ads_ad_groups_update
whosecpc_bid_micros(converted from micros to currency units) exceeds the
cap. Bids are per-auction ceilings with distinct semantics from spend
budgets, so they get their own caps. Thebid_constraints.roas_average_floor
(a min-ROAS floor) and Googlebid_modifier(a bid-adjustment multiplier)
are not spend amounts and are deliberately not constrained. Both fields are
optional and default to no check (backward compatible); an oversized bid
saturates to infinity and is refused rather than silently abstained. -
Meta Ads targeting-discovery tools (
meta_ads_targeting_search,
meta_ads_targeting_categories). Two read-only tools that resolve
Meta's internal targeting IDs needed to build an ad-settargeting
spec.meta_ads_targeting_searchsearches the interest catalogue by
keyword (query, optionallimit/locale) and returns interest
ids with audience-size bounds and path, for use in
targeting.flexible_spec/interests.meta_ads_targeting_categories
lists a full category catalogue for onecategory_class(behaviors,
demographics, life_events, industries, income, family_statuses,
user_device, user_os — mapped to Graph'sclassparam) for behavior /
demographic targeting where keyword search is unavailable. Both hit the
API-root/searchendpoint (not account-scoped) via a new
TargetingMixin; the class name is validated at the boundary. Meta Ads
tool count 84 → 86 (aggregate 201 → 203). -
Meta Ads bidding controls on campaign / ad-set write tools.
meta_ads_campaigns_createandmeta_ads_campaigns_updategain
bid_strategy(LOWEST_COST_WITHOUT_CAP,LOWEST_COST_WITH_BID_CAP,
COST_CAP,LOWEST_COST_WITH_MIN_ROAS);campaigns_createalso gains
is_adset_budget_sharing_enabled(required by Meta when creating a
campaign without campaign budget optimization).meta_ads_ad_sets_create
andmeta_ads_ad_sets_updategainbid_strategy,bid_constraints
(carryingroas_average_floorfor the min-ROAS strategy), and
promoted_object(the pixel/event conversion target);ad_sets_update
additionally exposesbid_amount(previously create-only, needed when
switching to a capped strategy). Combinations are passed through to Graph
rather than pre-validated, with the tool descriptions documenting the
cross-field requirements. Read paths now surface
is_adset_budget_sharing_enabled(campaigns) andpromoted_object
(ad sets). -
meta_ads_pages_listMCP tool. Read-only listing of the Facebook
Pages the current token can manage, aggregating personal
(/me/accounts) and business-owned (/me/businesses→owned_pages)
Pages. Backed by a newMetaAdsApiClient.list_pagesmethod that shares
the two-step page-discovery logic withget_page_access_token. -
meta_ads_pixels_createMCP tool. Creates a Meta Pixel on an ad
account via Graph APIPOST /act_{ad_account_id}/adspixelswith a required
name. Returns the created pixel id. Mutating and not automatically
reversible — pixels cannot be deleted through the Graph API — so the tool
description recommends callingmeta_ads_pixels_listfirst (ad accounts
have a pixel limit) and recording the change with
mureo_state_action_log_append. Backed by a newPixelsMixin.create_ad_pixel
client method (previously the mixin was read-only).
Changed
-
The Meta authentication step now opens with an explicit connection-method
chooser. The step used to render the prominent "Login with Facebook"
button with the system-user token card collapsed underneath, so operators
running a Live Meta app — for whom browser OAuth can never succeed,
Facebook rejects the localhost redirect — clicked the loud button anyway and
dead-ended. The two paths are mutually exclusive alternatives, so the step
now leads with "Choose a connection method" / "Pick one — you do not need
both." and two selectable option cards: Paste a system-user token
(listed first, badged Recommended: for Live apps and automation, never
expires, no browser login) and Log in with Facebook (for
Development-mode apps, renews about every 60 days). Nothing is preselected —
until the operator picks, neither the OAuth button nor the token form is
on screen — and choosing one reveals only that flow, with the token card
already expanded. The chooser is a framework-free radiogroup (role="radio"aria-checked, roving tabindex, Space/Enter, arrows and Home/End),
switching is idempotent and preserves typed-but-unsaved token text, and the
OAuth timeout guidance now points at the token option above. The card keeps
its
<details class="meta-token-card">shape and every existing
wizard.auth.meta_token_*key, so downstream markup hooks are unaffected.
-
The ad account in the Meta system-user token card is now explicitly
optional. After Validate, the account<select>was populated with the
probed accounts only — no placeholder — so the browser silently pre-selected
the first account and Save persisted it even if the operator never
opened the dropdown. The picker now opens on a "Select an ad account
(optional)" placeholder and carries a helper line explaining that the
selection can be left unset when the ad account is assigned elsewhere. Saving with the placeholder selected
postsaccount_id: nulland writes ameta_adsblock with noaccount_id
key (a previously saved selection is still carried forward). Also fixes the
route rejecting thatnullasaccount_not_accessible—str(None)is the
truthy string"None", so the optional path was unreachable (#459). -
Internal refactor: split the plugin declaration machinery out of
mureo.policy.strategy_gate.BudgetDeclaration,BidDeclaration, their
registries, and the register / lookup / reset helpers now live in the new
mureo.policy.declarationsmodule, keepingstrategy_gate.pywithin the
project file-size budget. No API change: every name is re-exported from
mureo.policy.strategy_gate, so all existing import paths remain stable. -
Strict input schemas across the entire builtin MCP tool registry.
Every builtin tool (all 203 across Google Ads, Meta Ads, Search Console,
rollback, analysis, mureo-context, analytics-registry, learning, and
creative-studio) now declaresadditionalProperties: falseon its
top-levelinputSchema. Previously an unknown or misspelled parameter
passed server-side validation and was then silently discarded by the
handler's whitelist — a typo likebudgettbecame a no-op. Such a call
now fails validation with a clear error naming the offending key.
Breaking for any caller that relied on typo-tolerance / passing extra
keys. Nested object properties (e.g. Meta'stargetingblob) are
intentionally left open so they stay forward-compatible with the
platform Graph/GAQL sub-object surfaces, which evolve independently of
mureo releases. A new enforcement test
(tests/test_mcp_strict_input_schemas.py) guards the invariant so any
future tool that omits it fails CI. Generalizes the earlier four-tool
change below to the whole registry. -
Stricter input validation on the four Meta Ads campaign / ad-set write
tools.meta_ads_campaigns_create,meta_ads_campaigns_update,
meta_ads_ad_sets_create, andmeta_ads_ad_sets_updatenow declare
additionalProperties: false, so an unknown / misspelled parameter is
rejected with a clear schema error instead of being silently dropped.
Fixed
meta_ads_images_upload_filereturned 400 for every file, and swallowed
Meta's error detail. The ad-image uploader (upload_ad_image_file) sent the
file as amultipart/form-dataupload to Graph/adimages, which Meta
rejected withFileTypeNotSupported(error_subcode1487411) for every file
regardless of format — even after the multipart body was verified to be
provably well-formed (correct boundary,image/pngpart header, raw magic
bytes, no double base64). Multipart to this endpoint is a dead end, so the
uploader now sends the image via the base64bytesform-body variant — the
same shape the URL-basedupload_ad_imagepath already uses successfully in
production: it reads the file, base64-encodes it, and posts
{"bytes": <base64>}as a plain form body. Separately, the method built its
own one-offhttpx.AsyncClientand calledraise_for_status(), so the user
only ever saw httpx's genericClient error '400 Bad Request'while Meta's
real diagnostics were discarded. It now routes through the shared_request
machinery (retries, 429/backoff and rate-limit monitoring included), and that
machinery now surfaceserror_subcodeandfbtrace_idalongside the existing
error.message, so a failed upload reports Meta's actual reason (this
error-surfacing portion is field-confirmed working). The upload's auth shape
also changed to match the shared machinery: the access token is no longer sent
as anaccess_tokenform field and instead rides theAuthorization: Bearer
header only. Thenameparameter is retained for API compatibility but is no
longer transmitted (thebytesvariant has no documentednamefield).