Skip to content

MCP server

mureo exposes 228 tools via the Model Context Protocol (MCP): 192 advertising and SEO operation tools across Google Ads (92), Meta Ads (90), and Search Console (10), 2 rollback tools, 3 batch tools (group a bulk change into one revertible unit), 1 change-import tool (record changes made outside mureo), 5 cross-platform analysis tools (anomaly detection, delivery-collapse detection and diagnosis, the exclusion delivery-impact preview and tracking-parameter consistency), 15 mureo-context tools (strategy / state / reports / the dashboard’s display contract / per-day history / collection failures — per platform and for the workspace as a whole — / outcome evaluation / decision records / history queries), 2 analytics-registry tools, 2 learning tools (mureo_learning_insights_get for the operator’s local /learn history and mureo_consult_advisor for federated retrieval against external advisor MCP servers — see docs/insight-federation.md), 1 learning-period pre-flight tool (mureo_learning_reset_preflight — is a pending change reset-triggering, and is the campaign already learning; see Learning-period reset pre-flight), and 5 Creative Studio tools (text-free key-visual generation + banner composition). Any MCP-compatible client can connect and call these tools over stdio. Re-check this count when MCP tools are added or removed (test_list_tools_returns_all_tools pins the exact number). The count covers mureo’s own tool families only — tools bridged from the official Amazon Ads MCP (and from any installed provider plugin) are appended on top at server start and vary per operator; see Amazon Ads (official-MCP bridge) below.

Terminal window
pip install mureo
# Start the MCP server
python -m mureo.mcp

The server communicates over stdin/stdout using the MCP JSON-RPC protocol. It is not meant to be run interactively — it should be launched by an MCP client.

Add to your MCP configuration (~/.config/claude/mcp.json or the app’s settings):

{
"mcpServers": {
"mureo": {
"command": "python",
"args": ["-m", "mureo.mcp"]
}
}
}

Add to .cursor/mcp.json in your project root:

{
"mcpServers": {
"mureo": {
"command": "python",
"args": ["-m", "mureo.mcp"]
}
}
}

Codex reads MCP servers from ~/.codex/config.toml (TOML, not JSON) — shared across the Codex CLI, IDE extension, and desktop app. The easiest path is mureo setup codex (or mureo configure → pick the OpenAI Codex host), which writes a tagged [mcp_servers.mureo] block and preserves the rest of the file. To wire it by hand:

[mcp_servers.mureo]
command = "python"
args = ["-m", "mureo.mcp"]

If mureo is installed in a virtual environment, use the full path to the Python interpreter:

{
"mcpServers": {
"mureo": {
"command": "/path/to/venv/bin/python",
"args": ["-m", "mureo.mcp"]
}
}
}

Or use uv to run it:

{
"mcpServers": {
"mureo": {
"command": "uv",
"args": ["run", "python", "-m", "mureo.mcp"]
}
}
}
ToolDescriptionRequired Parameters
google_ads_campaigns_listList campaignscustomer_id
google_ads_campaigns_getGet campaign detailscustomer_id, campaign_id
google_ads_campaigns_createCreate a campaign (search or display, via channel_type)customer_id, name
google_ads_campaigns_updateUpdate campaign settingscustomer_id, campaign_id
google_ads_campaigns_update_statusChange status (ENABLED/PAUSED/REMOVED)customer_id, campaign_id, status
google_ads_campaigns_diagnoseDiagnose campaign deliverycustomer_id, campaign_id
ToolDescriptionRequired Parameters
google_ads_ad_groups_listList ad groupscustomer_id
google_ads_ad_groups_createCreate an ad groupcustomer_id, campaign_id, name
google_ads_ad_groups_updateUpdate an ad groupcustomer_id, ad_group_id
ToolDescriptionRequired Parameters
google_ads_ads_listList adscustomer_id
google_ads_ads_createCreate a responsive search ad (RSA)customer_id, ad_group_id, headlines, descriptions
google_ads_ads_create_displayCreate a responsive display ad (RDA); image files are uploaded automaticallycustomer_id, ad_group_id, headlines, long_headline, descriptions, business_name, marketing_image_paths, square_marketing_image_paths, final_url
google_ads_ads_updateUpdate an adcustomer_id, ad_group_id, ad_id
google_ads_ads_update_statusChange ad statuscustomer_id, ad_group_id, ad_id, status
google_ads_ads_policy_detailsGet ad policy approval detailscustomer_id, ad_group_id, ad_id
ToolDescriptionRequired Parameters
google_ads_keywords_listList keywordscustomer_id
google_ads_keywords_addAdd keywordscustomer_id, ad_group_id, keywords
google_ads_keywords_removeRemove a keywordcustomer_id, ad_group_id, criterion_id
google_ads_keywords_suggestGet keyword suggestions (Keyword Planner)customer_id, seed_keywords
google_ads_keywords_diagnoseDiagnose keyword quality scorescustomer_id, campaign_id
google_ads_keywords_pausePause a keywordcustomer_id, ad_group_id, criterion_id
google_ads_keywords_auditAudit keyword performance and qualitycustomer_id, campaign_id
google_ads_keywords_cross_adgroup_duplicatesFind duplicate keywords across ad groupscustomer_id, campaign_id
ToolDescriptionRequired Parameters
google_ads_negative_keywords_listList negative keywordscustomer_id, campaign_id
google_ads_negative_keywords_addAdd negative keywords to a campaigncustomer_id, campaign_id, keywords
google_ads_negative_keywords_removeRemove a negative keywordcustomer_id, campaign_id, criterion_id
google_ads_negative_keywords_add_to_ad_groupAdd negative keywords to an ad groupcustomer_id, ad_group_id, keywords
google_ads_negative_keywords_suggestSuggest negative keywords based on search termscustomer_id, campaign_id

Negative Placements (delivery-surface exclusions)

Section titled “Negative Placements (delivery-surface exclusions)”

Excluded websites, mobile apps and mobile app categories — the placement side of exclusion, as opposed to the search-term side above.

ToolDescriptionRequired Parameters
google_ads_negative_placements_listList excluded websites / apps / app categories at campaign and ad group levelcustomer_id
google_ads_negative_placements_addExclude websites / apps / app categories (batch, one revertible unit)customer_id, one of campaign_id / ad_group_id, placements
google_ads_negative_placements_removeLift exclusions by criterion_id (batch)customer_id, one of campaign_id / ad_group_id, criterion_ids
ToolDescriptionRequired Parameters
google_ads_budget_getGet campaign budgetcustomer_id, campaign_id
google_ads_budget_updateUpdate budgetcustomer_id, budget_id, amount
google_ads_budget_createCreate a new campaign budgetcustomer_id, name, amount
ToolDescriptionRequired Parameters
google_ads_accounts_listList accessible Google Ads accounts(none)
ToolDescriptionRequired Parameters
google_ads_search_terms_reportGet search terms reportcustomer_id
google_ads_search_terms_analyzeAnalyze search terms with intent classificationcustomer_id, campaign_id
ToolDescriptionRequired Parameters
google_ads_sitelinks_listList sitelink extensionscustomer_id, campaign_id
google_ads_sitelinks_createCreate a sitelink extensioncustomer_id, campaign_id, sitelink_text, final_url
google_ads_sitelinks_removeRemove a sitelink extensioncustomer_id, campaign_id, extension_id
ToolDescriptionRequired Parameters
google_ads_callouts_listList callout extensionscustomer_id, campaign_id
google_ads_callouts_createCreate a callout extensioncustomer_id, campaign_id, callout_text
google_ads_callouts_removeRemove a callout extensioncustomer_id, campaign_id, extension_id
ToolDescriptionRequired Parameters
google_ads_conversions_listList conversion actionscustomer_id
google_ads_conversions_getGet conversion action detailscustomer_id, conversion_action_id
google_ads_conversions_performanceGet conversion performance metricscustomer_id
google_ads_conversions_createCreate a conversion actioncustomer_id, name, type
google_ads_conversions_updateUpdate a conversion actioncustomer_id, conversion_action_id
google_ads_conversions_removeRemove a conversion actioncustomer_id, conversion_action_id
google_ads_conversions_tagGet conversion tracking tag snippetcustomer_id, conversion_action_id
ToolDescriptionRequired Parameters
google_ads_recommendations_listList optimization recommendationscustomer_id
google_ads_recommendations_applyApply an optimization recommendationcustomer_id, recommendation_id
google_ads_device_targeting_getGet device targeting settingscustomer_id, campaign_id
google_ads_device_targeting_setSet device targeting bid adjustmentscustomer_id, campaign_id, device_type, bid_modifier
google_ads_bid_adjustments_getGet bid adjustment settingscustomer_id, campaign_id
google_ads_bid_adjustments_updateUpdate bid adjustmentscustomer_id, campaign_id
google_ads_location_targeting_listList location targeting criteriacustomer_id, campaign_id
google_ads_location_targeting_updateUpdate location targetingcustomer_id, campaign_id
google_ads_schedule_targeting_listList ad schedule targetingcustomer_id, campaign_id
google_ads_schedule_targeting_updateUpdate ad schedule targetingcustomer_id, campaign_id
google_ads_change_history_listList account change historycustomer_id
google_ads_demographic_targeting_listList explicit demographic criteria (age range, gender, parental status, household income) set on ad groupscustomer_id
google_ads_audience_targeting_listList audience-type criteria (user interests, remarketing / customer-match lists, custom / combined audiences) attached to ad groupscustomer_id
ToolDescriptionRequired Parameters
google_ads_performance_reportGet performance reportcustomer_id
google_ads_performance_analyzeAnalyze performance trends and anomaliescustomer_id
google_ads_cost_increase_investigateInvestigate sudden cost increasescustomer_id, campaign_id
google_ads_health_check_allRun a comprehensive account health checkcustomer_id
google_ads_ad_performance_compareCompare ad performance across variantscustomer_id, ad_group_id
google_ads_ad_performance_reportGet detailed ad-level performance reportcustomer_id
google_ads_network_performance_reportGet network-level performance breakdowncustomer_id
google_ads_budget_efficiencyAnalyze budget utilization efficiencycustomer_id
google_ads_budget_reallocationSuggest budget reallocation across campaignscustomer_id
google_ads_auction_insights_getGet auction insights (competitor analysis)customer_id, campaign_id
google_ads_auction_insights_analyzeInterpret impression-share metrics into human-readable competitive-position insightscustomer_id, campaign_id
google_ads_rsa_assets_analyzeAnalyze RSA asset performancecustomer_id, ad_group_id
google_ads_rsa_assets_auditAudit RSA assets for best practicescustomer_id, campaign_id
google_ads_search_terms_reviewReview search terms with rule-based scoringcustomer_id, campaign_id
ToolDescriptionRequired Parameters
google_ads_btob_optimizationsGet B2B-specific optimization suggestionscustomer_id
ToolDescriptionRequired Parameters
google_ads_landing_page_analyzeAnalyze landing page relevance and qualitycustomer_id, campaign_id
google_ads_creative_researchResearch competitive creative strategiescustomer_id
ToolDescriptionRequired Parameters
google_ads_monitoring_delivery_goalMonitor campaign delivery against goalscustomer_id, campaign_id
google_ads_monitoring_cpa_goalMonitor CPA against target goalscustomer_id, campaign_id
google_ads_monitoring_cv_goalMonitor conversion volume against goalscustomer_id, campaign_id
google_ads_monitoring_zero_conversionsDetect campaigns with zero conversionscustomer_id
ToolDescriptionRequired Parameters
google_ads_capture_screenshotCapture a screenshot of a URLurl
ToolDescriptionRequired Parameters
google_ads_device_analyzeAnalyze device-level performancecustomer_id, campaign_id
google_ads_cpc_detect_trendDetect CPC trend (rising/stable/falling)customer_id, campaign_id
ToolDescriptionRequired Parameters
google_ads_assets_upload_imageUpload a local image file as a Google Ads assetcustomer_id, file_path
google_ads_image_assets_listList existing image assets with their names and dimensions (width/height, file size, serving URL)customer_id

google_ads_ads_list returns no rows for a Performance Max campaign: P-MAX has no ad_group_ad. Its headlines, long headlines and descriptions — and its images and logos — are assets linked to an asset group through asset_group_asset. These three tools are the P-MAX creative surface.

ToolDescriptionRequired Parameters
google_ads_asset_group_assets_listList the text AND image assets linked to Performance Max asset groups, with the asset_id and the asset_group_asset handle for each. Optionally filtered by asset_group_id or campaign_idcustomer_id
google_ads_asset_group_assets_replaceSwap one headline, long headline or description of an asset group for new textcustomer_id, asset_group_id, field_type, old_asset_id, new_text
google_ads_asset_group_images_replaceSwap one image or logo of an asset group, from an existing asset id or a local filecustomer_id, asset_group_id, field_type, old_asset_id, and one of new_asset_id / new_image_path

One read, two row shapes. google_ads_asset_group_assets_list issues one query covering both halves, because the question is “show me this asset group’s creative”, not “show me its text”. Every entry carries resource_name, field_type, status, asset_id, asset_group_id, asset_group_name, campaign_id and campaign_resource_name; field_type says what the rest holds. A text link (HEADLINE, LONG_HEADLINE, DESCRIPTION) adds text. An image link (MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE, LOGO, LANDSCAPE_LOGO) adds asset_name, url (the full-size serving URL), width_pixels and height_pixels. This is also the only Google read that says which asset group serves a given image: google_ads_image_assets_list is account-wide and does not.

The text swap. A Google Ads text Asset is immutable, so the swap is not an update: it creates a new Asset, links it under the same field_type, and removes the old link. All three go in one atomic GoogleAdsService.mutate, so the asset group’s asset count for that field type never dips below the Performance Max minimum — a removal issued on its own can be refused with AssetGroupError.NOT_ENOUGH_HEADLINE_ASSET (or the long-headline / description twin), which the tool reports as an actionable message rather than a raw API error. The old Asset itself is not deleted; only its link to that asset group is.

The image swap. Nothing has to be created inside the mutate — an image asset exists before an asset group can point at it — so the request is two operations, link then unlink, again as one atomic mutate against the same NOT_ENOUGH_MARKETING_IMAGE_ASSET / NOT_ENOUGH_SQUARE_MARKETING_IMAGE_ASSET / NOT_ENOUGH_LOGO_ASSET floors. Pass new_asset_id for an image the account already holds, or new_image_path for a local file — exactly one of the two; the tool uploads the file itself, so an operator never has to work out which situation they are in. Google enforces a shape per slot (MARKETING_IMAGE 1.91:1 min 600x314, SQUARE_MARKETING_IMAGE 1:1 min 300x300, PORTRAIT_MARKETING_IMAGE 4:5 min 480x600, LOGO 1:1 min 128x128, LANDSCAPE_LOGO 4:1 min 512x128) and mureo checks it before uploading or linking anything, so a wrongly proportioned file costs no API call and leaves no unlinked asset behind. For a format it cannot measure locally (GIF, or an unrecognised header) it does not guess: the file goes to Google and an ImageError refusal comes back translated into the rule for that slot. mureo never crops or resizes.

Neither swap is automatically reversible: to undo one, call the tool again with the old text or the old asset_id.

Text and images only. Video, business name, and every other field type of an asset group are neither returned by google_ads_asset_group_assets_list nor replaceable. A video asset references a YouTube video id rather than uploaded bytes, so it is a different entry shape and a different operator workflow. /creative-refresh treats every surface with no write tool as draft-only and says so before drafting rather than after the operator agrees; see its Apply or draft section.

ToolDescriptionRequired Parameters
meta_ads_campaigns_listList campaignsaccount_id
meta_ads_campaigns_getGet campaign detailsaccount_id, campaign_id
meta_ads_campaigns_createCreate a campaignaccount_id, name, objective
meta_ads_campaigns_updateUpdate a campaignaccount_id, campaign_id
meta_ads_campaigns_pausePause a campaignaccount_id, campaign_id
meta_ads_campaigns_enableEnable a paused campaignaccount_id, campaign_id
ToolDescriptionRequired Parameters
meta_ads_ad_sets_listList ad setsaccount_id
meta_ads_ad_sets_createCreate an ad setaccount_id, campaign_id, name, daily_budget
meta_ads_ad_sets_updateUpdate an ad setaccount_id, ad_set_id
meta_ads_ad_sets_getGet ad set detailsaccount_id, ad_set_id
meta_ads_ad_sets_pausePause an ad setaccount_id, ad_set_id
meta_ads_ad_sets_enableEnable a paused ad setaccount_id, ad_set_id
ToolDescriptionRequired Parameters
meta_ads_ads_listList adsaccount_id
meta_ads_ads_createCreate an adaccount_id, ad_set_id, name, creative_id
meta_ads_ads_updateUpdate an adaccount_id, ad_id
meta_ads_ads_getGet ad detailsaccount_id, ad_id
meta_ads_ads_pausePause an adaccount_id, ad_id
meta_ads_ads_enableEnable a paused adaccount_id, ad_id
ToolDescriptionRequired Parameters
meta_ads_creatives_listList ad creativesaccount_id
meta_ads_creatives_createCreate a standard ad creative (single image or video)account_id, name
meta_ads_creatives_create_carouselCreate a carousel creative (2-10 cards)account_id, page_id, cards, link
meta_ads_creatives_create_collectionCreate a collection creativeaccount_id, page_id, product_ids, link
meta_ads_creatives_create_dynamicCreate a dynamic product ad creativeaccount_id, catalog_id
meta_ads_creatives_create_leadCreate a Lead Ad creative attached to an Instant Formaccount_id, name, page_id, form_id, link_url
meta_ads_creatives_upload_imageUpload an image for use in creativesaccount_id, file_path
ToolDescriptionRequired Parameters
meta_ads_images_upload_fileUpload an image from local fileaccount_id, file_path
ToolDescriptionRequired Parameters
meta_ads_insights_reportGet performance reportaccount_id
meta_ads_insights_breakdownGet breakdown report (age, gender, etc.)account_id, campaign_id
ToolDescriptionRequired Parameters
meta_ads_audiences_listList custom audiencesaccount_id
meta_ads_audiences_createCreate a custom audienceaccount_id, name, subtype
meta_ads_audiences_getGet audience detailsaccount_id, audience_id
meta_ads_audiences_deleteDelete a custom audienceaccount_id, audience_id
meta_ads_audiences_create_lookalikeCreate a lookalike audienceaccount_id, source_audience_id, country
ToolDescriptionRequired Parameters
meta_ads_targeting_searchResolve interest names to internal targeting IDs (with audience-size bounds and path)query
meta_ads_targeting_categoriesList a full targeting category catalogue (behaviors / demographics / etc.) with internal IDscategory_class
ToolDescriptionRequired Parameters
meta_ads_conversions_sendSend conversion events (generic)account_id, pixel_id, events
meta_ads_conversions_send_purchaseSend a purchase eventaccount_id, pixel_id, event_time, user_data, currency, value
meta_ads_conversions_send_leadSend a lead eventaccount_id, pixel_id, event_time, user_data
ToolDescriptionRequired Parameters
meta_ads_pixels_listList pixelsaccount_id
meta_ads_pixels_getGet pixel detailsaccount_id, pixel_id
meta_ads_pixels_statsGet pixel firing statisticsaccount_id, pixel_id
meta_ads_pixels_eventsList pixel eventsaccount_id, pixel_id
meta_ads_pixels_createCreate a pixelaccount_id, name
ToolDescriptionRequired Parameters
meta_ads_analysis_performanceAnalyze overall performance trendsaccount_id
meta_ads_analysis_audienceAnalyze audience performance and overlapaccount_id
meta_ads_analysis_placementsAnalyze placement performance breakdownaccount_id
meta_ads_analysis_costAnalyze cost trends and efficiencyaccount_id
meta_ads_analysis_compare_adsCompare performance across adsaccount_id
meta_ads_analysis_suggest_creativeSuggest creative improvements based on dataaccount_id

Which publishers, Audience Network app categories and content types an ad set must NOT be delivered against. Stored inside the ad set’s targeting spec; named as their own tools so mureo can record and reverse an exclusion change.

ToolDescriptionRequired Parameters
meta_ads_excluded_placements_getRead an ad set’s current exclusion listsaccount_id, ad_set_id
meta_ads_excluded_placements_setReplace the supplied exclusion facets on an ad setaccount_id, ad_set_id
ToolDescriptionRequired Parameters
meta_ads_catalogs_listList product catalogsaccount_id, business_id
meta_ads_catalogs_createCreate a product catalogaccount_id, business_id, name
meta_ads_catalogs_getGet catalog detailsaccount_id, catalog_id
meta_ads_catalogs_deleteDelete a product catalogaccount_id, catalog_id
meta_ads_products_listList products in a catalogaccount_id, catalog_id
meta_ads_products_addAdd a product to a catalogaccount_id, catalog_id, retailer_id, name, availability, condition, price, url, image_url
meta_ads_products_getGet product detailsaccount_id, product_id
meta_ads_products_updateUpdate a productaccount_id, product_id
meta_ads_products_deleteDelete a productaccount_id, product_id
meta_ads_feeds_listList feeds for a catalogaccount_id, catalog_id
meta_ads_feeds_createCreate a feed (URL-based, scheduled import)account_id, catalog_id, name, feed_url
ToolDescriptionRequired Parameters
meta_ads_lead_forms_listList lead forms (per page)account_id, page_id
meta_ads_lead_forms_getGet lead form detailsaccount_id, form_id
meta_ads_lead_forms_createCreate a lead formaccount_id, page_id, name, questions, privacy_policy_url, follow_up_action_url
meta_ads_lead_forms_updateUpdate lead form status (ACTIVE / ARCHIVED)account_id, form_id, status
meta_ads_lead_forms_duplicateDuplicate a lead form under a Page with a new nameaccount_id, form_id, page_id, new_name
meta_ads_leads_export_csvExport form leads to a local CSV fileaccount_id, form_id, output_path
meta_ads_leads_getGet lead data (per form)account_id, form_id
meta_ads_leads_get_by_adGet lead data (per ad)account_id, ad_id
meta_ads_pages_photos_listList photos the Page already uploaded; pick one id for an Instant Form intro context_card.cover_photo_idaccount_id, page_id
ToolDescriptionRequired Parameters
meta_ads_pages_listList manageable Facebook Pages (personal + business-owned)account_id
ToolDescriptionRequired Parameters
meta_ads_videos_uploadUpload a video from URLaccount_id, video_url
meta_ads_videos_upload_fileUpload a video from local fileaccount_id, file_path
meta_ads_videos_getGet video processing status / metadata (poll before creating a creative)account_id, video_id
meta_ads_videos_thumbnailsList auto-generated video thumbnailsaccount_id, video_id

(See Creatives section above for carousel and collection tools.)

ToolDescriptionRequired Parameters
meta_ads_split_tests_listList split testsaccount_id
meta_ads_split_tests_getGet split test details and resultsaccount_id, study_id
meta_ads_split_tests_createCreate a split testaccount_id, name, cells, objectives, start_time, end_time
meta_ads_split_tests_endEnd a split testaccount_id, study_id
ToolDescriptionRequired Parameters
meta_ads_ad_rules_listList automated rulesaccount_id
meta_ads_ad_rules_getGet rule detailsaccount_id, rule_id
meta_ads_ad_rules_createCreate an automated rule (alerts, auto-pause, etc.)account_id, name, evaluation_spec, execution_spec
meta_ads_ad_rules_updateUpdate an automated ruleaccount_id, rule_id
meta_ads_ad_rules_deleteDelete an automated ruleaccount_id, rule_id
ToolDescriptionRequired Parameters
meta_ads_page_posts_listList Facebook page postsaccount_id, page_id
meta_ads_page_posts_boostBoost a page post (create ad from post)account_id, page_id, post_id, ad_set_id
ToolDescriptionRequired Parameters
meta_ads_instagram_accountsList connected Instagram accountsaccount_id
meta_ads_instagram_mediaList Instagram postsaccount_id, ig_user_id
meta_ads_instagram_boostBoost an Instagram post (create ad from post)account_id, ig_user_id, media_id, ad_set_id

Search Console tools reuse the same Google OAuth2 credentials as Google Ads — no additional authentication is required.

site_url is an optional parameter on every tool below, not a schema-required one. It is resolved (and tenant-scoped) at runtime: in standalone use you pass it explicitly; under a multi-account backend it is bound to the active client’s configured property (and a single-property client may omit it). The “Required Parameters” column lists only the schema-required fields.

ToolDescriptionRequired Parameters
search_console_sites_listList verified sites(none)
search_console_sites_getGet site details(none; site_url optional)
ToolDescriptionRequired Parameters
search_console_analytics_queryQuery search analytics datastart_date, end_date
search_console_analytics_top_queriesGet top search queriesstart_date, end_date
search_console_analytics_top_pagesGet top pages by clicks/impressionsstart_date, end_date
search_console_analytics_device_breakdownGet performance breakdown by devicestart_date, end_date
search_console_analytics_compare_periodsCompare search performance across time periodsstart_date_1, end_date_1, start_date_2, end_date_2
ToolDescriptionRequired Parameters
search_console_sitemaps_listList sitemaps for a site(none; site_url optional)
search_console_sitemaps_submitSubmit a sitemapfeedpath
ToolDescriptionRequired Parameters
search_console_url_inspection_inspectInspect a URL for indexing statusinspection_url

Cross-platform tools for inspecting and applying the reversal of a previously-recorded action_log entry. rollback_apply re-dispatches through the same MCP handler used for forward actions, so the reversal re-enters the full policy gate (auth, rate-limit, GAQL validation, planner allow-list).

ToolDescriptionRequired Parameters
rollback_plan_getInspect the reversal plan for one action_log entry (supported / partial / not_supported), its operation + params, and any caveats — or, with batch_id instead, for a whole batch (see below). Read-only.exactly one of index / batch_id
rollback_applyExecute the reversal plan for action_log[index]. Requires confirm=true as a literal boolean. Appends a new log entry tagged rollback_of=<index>.index, confirm

Both tools accept an optional state_file argument (default STATE.json), which is resolved strictly inside the MCP server’s current working directory. Path traversal, symlink escape, and rollback.* self-recursion are all refused. A second apply of the same index is refused (idempotency is enforced by scanning later log entries for a matching rollback_of marker). Downstream SDK exceptions are logged server-side only; the MCP response returns a generic message so tokens and account identifiers cannot leak into model context.

rollback_plan_get with batch_id returns one plan covering every member of that batch (see Batch below for how membership is declared), so a bulk pass is reviewed as one unit instead of entry by entry:

FieldMeaning
coveragefull / partial / none / empty — how much of the batch a reversal would actually restore
platform_coverageThe same verdict per platform key, because reversibility is not uniform across platforms
countsMembers per verdict (reversible, reversible_with_caveats, irreversible, nothing_to_reverse, already_reversed, total)
apply_orderThe reversible members’ action_log indices, newest first — the order to feed rollback_apply
members[]Every member with its index, platform, reversibility verdict, the reason when it cannot be reversed, and its operation / params / caveats when it can

The point of the response is the part that is not reversible. A batch where 60 of 80 members can be restored reports coverage: "partial" with the other 20 listed and explained, before anything is applied — a revert whose completeness the operator cannot verify leaves them unable to rule their own fix out as a variable.

rollback_plan_get is read-only and rollback_apply still takes one index at a time, so applying a batch reversal is a loop over apply_order — each call re-entering the same policy gate as a forward action. There is deliberately no “apply the whole batch” call: a single result code for 80 dispatches would have to summarize partial failure, which is the reporting problem this feature exists to remove.

Declare the boundary of a bulk change so it becomes one reviewable, plannable unit in action_log. A bulk pass is many tool calls and nothing in a single call says which others belong with it, so the boundary is declared rather than guessed.

ToolDescriptionRequired Parameters
mureo_batch_beginOpen a batch. Every action_log entry recorded until it is closed is tagged with the returned batch_id. Refused if one is already open.label
mureo_batch_endClose the open batch and return its exact membership (member_indices, member_count, platforms). Closing is final. Refused if none is open.(none)
mureo_batch_statusReport which batch is collecting (or null), how many members it holds, which platforms they span, and a warning when it has been open too long. Read-only.(none)

Membership cannot be forged or grown after the fact. mureo_state_action_log_append accepts an optional batch_id, but it is validated, not trusted: it must name a batch that was actually declared and is still open. An unknown id is refused (an id naming no batch is a typo or a fabrication, not a change set), and a closed batch is refused too — mureo_batch_end reports a member_count the operator keeps, and a membership that can still grow afterwards makes that number silently false. To group imported or backfilled history, open a batch for the import rather than reattaching to an old one.

A forgotten mureo_batch_end announces itself. A missed begin yields no batch, which is obvious and harmless; a missed end yields a batch that keeps swallowing unrelated changes for days and then reports them, confidently, as one unit. After 24 hours open, mureo_batch_status returns a warning, and one is appended to the result of every mutating tool call so the agent that forgot is told without having to ask. mureo never closes a batch for you — an automatic timeout would trade a visible wrong answer for an invisible one. Suppress the appended reminder with MUREO_DISABLE_BATCH_REMINDER=1.

Membership is stamped where every recording path already converges (append_action_log), not through tool arguments — which is what makes it work for platforms whose tool schemas mureo does not own. What that means per platform:

Platform kindJoins a batchReversal of a member
Native Google Ads / Meta AdsYes. Status toggles are recorded automatically; every other mutation (budget, keywords, placement exclusions, …) joins only if the agent records it with mureo_state_action_log_appendExecuted, for the allow-listed operations
Bridged / plugin (plugin:<dist>:<provider>, e.g. Amazon Ads)Yes — successful mutations are promoted to action_log automaticallyRecorded for visibility; executed only when the reversal names a registered plugin tool. Otherwise the member is reported irreversible with the reason
Hosted connectors (tiktok_ads)Yes, for entries recorded with mureo_state_action_log_append — mureo is not in the data path, so nothing is automaticNot executed by design. Members are reported irreversible, so the batch plan is an accurate manual checklist
Search ConsoleNo. Its mutations (sitemaps_submit) are not recorded in action_log at all, so there is nothing to groupn/a

Batch state lives in STATE.json (batches), not in process memory, so a host that restarts the MCP server mid-pass does not silently stop collecting members. Records are kept after close (with ended_at) so a batch_id still resolves to the operator’s own label weeks later.

Record changes made outside mureo — in a platform’s own UI, its editor, or another tool — so mureo’s guarantees survive manual operation. Without this, mureo cannot tell “nothing happened” from “something happened that I cannot see”.

ToolDescriptionRequired Parameters
mureo_external_changes_importPoll each configured platform’s change feed and append anything mureo did not do to action_log with origin: "external". Skips changes already imported and changes mureo itself made. Idempotent.(none)

Optional: platforms (array of platform keys; omit to cover every platform in STATE.json), since (ISO 8601 window start; omit to resume from the newest change already imported), path.

The response is designed to be read for blind spots, not just for finds. Every configured platform appears in platforms[]:

statusMeaning
importedThe feed ran. An empty imported_indices here is a real “nothing changed in this window”
unavailableThe platform was not checked: either mureo has no change feed for it, or a registered feed could not answer for this account/mode (BYOD, unsupported account type). reason: "change_import_unavailable_for_<platform>"; the specific cause is in notes. Not evidence that nothing happened
errorThe feed exists but could not be read (expired token, missing credentials). Also unchecked, also not evidence of quiet

blind_spots collects the unavailable and error platforms; truncated_platforms collects those whose feed capped its response, meaning older changes inside that window are unreachable and cannot be recovered later. feeds_available_for lists the platforms that have a registered feed at all.

Per-platform coverage — which feeds exist, which mureo reads today, and what each omits — is in docs/change-import.md. Do not infer it from tool availability.

An imported entry is permanently distinguishable from one mureo performed. It carries origin: "external", the platform’s occurred_at, and an observation_due anchored on that (so an older change lands already past due), and no metrics_at_action. rollback_plan_get returns not_supported for every external entry — mureo never saw the prior value, so a “reversal” would be a fresh change dressed as a restoration.

For a hosted connector mureo cannot poll (tiktok_ads), a skill that reads the connector’s own change tools records what it finds through mureo_state_action_log_append with origin: "external" plus external_id and occurred_at.

Bridges and plugins participate by shipping an entry point in the mureo.change_feeds group implementing ChangeFeedProvider — a new Protocol in a new group, so no published plugin is affected. See docs/ABI-stability.md §4b.

Cross-platform analysis that operates on data the caller supplies — an action_log history, ad records, a delivery report — rather than on a platform API the tool picks for itself. analysis_anomalies_check, both delivery-collapse tools and analysis_tracking_consistency_check reach no platform API at all, so they behave identically for native, plugin, bridged and hosted-connector platforms; analysis_exclusion_impact_preview issues one read of the account’s own report unless the caller supplies delivery_records.

ToolDescriptionRequired Parameters
analysis_anomalies_checkCompare a campaign’s current metrics against a median-based baseline built from action_log history. Returns severity-ordered anomalies — zero spend (CRITICAL), CPA spike (HIGH/CRITICAL, gated by 30+ conversions), CTR drop (HIGH/CRITICAL, gated by 1000+ impressions).current (current.campaign_id and current.cost required)
analysis_delivery_collapse_checkFlag campaigns whose impressions fell off a cliff while their status still says they should be serving. Baseline comes from the supplied day-grain rows, not from action_log.platform, rows
analysis_delivery_collapse_diagnoseOverlay a change feed on one campaign’s daily delivery, fold in elimination-ladder evidence, and report the cause and the open questions. change_lookback_days (default 3) and timeline_days (default 21) are settable — widen the lookback for a cause with a delayed effect.platform, campaign_id, rows

google_ads_cost_increase_investigate answers “why did spend jump?”. These two answer its inverse — a campaign that is still ENABLED while its impressions have gone to zero, which is the most detectable failure mode in ad operations and the one mureo previously had no scheduled detector for.

Why not analysis_anomalies_check. That tool must be hand-fed one campaign’s current metrics and baselines them off action_log, which is thin or empty on accounts operated partly by hand. analysis_delivery_collapse_check takes a whole day-grain report and derives its baseline from those rows (baseline_source: "platform_daily_delivery"), so it works with an empty action log.

Row shape — one row per campaign per day, ~30+ days:

{"campaign_id": "123", "campaign_name": "Display / Prospecting", "status": "ENABLED",
"end_date": "2026-12-31", "date": "2026-05-31", "impressions": 0, "clicks": 0, "cost": 0}

What it will not fire on. The false-positive suppression is the reason the detector is usable unattended:

  • Weekend / weekday seasonality — the baseline is the median of the same weekday in the trailing window (same_weekday_median, falling back to all_day_median when a weekday has too few samples). A 96% Saturday dip on a weekday-heavy account is normal and is not reported.
  • Intraday budget pacing — the current day is always partial, so days at or after as_of (the server’s today, overridable for tests) are never evaluated.
  • Intentional pauses — a campaign whose status is not a serving status is skipped. The status says serving, nothing is serving contradiction is the entire signal.
  • Finished flights — a campaign past its end_date is expected to stop.
  • Low-volume campaigns — a baseline under delivery_collapse_min_baseline_impressions (default 1000/day) hits zero routinely.
  • New campaigns — fewer than delivery_collapse_min_baseline_days (default 14) days with real delivery in the window yields no signal. It counts delivering days, not window length, so a campaign cannot reach the bar on days it was already down.

It reports reported_through (the latest date the platform reported anything) and unreported_days alongside signals. An empty signals list is only an all-clear when unreported_days is 0 — see What detection cannot see below.

Two things it deliberately does not depend on:

  • How long the outage has been running. Detection is asserted across the whole duration range (1 day to 180+), because a detector that silently stops firing on the longest outages is worst exactly where it matters most.

  • Whether a platform emits zero-delivery rows. Google Ads and Meta both omit a (campaign, date) row when nothing served, which is the very symptom being looked for. Missing days are reconciled to explicit zeros — but only where the report proves the platform covered them. A platform that already returns zeros produces no gaps, so the reconciliation is a no-op for it.

    The proof matters, and it is the difference between a working detector and one that gets muted. A gap bracketed by later rows (the campaign’s own, or any other campaign in the account) is certain: the platform reported past it, so nothing served. A gap beyond the last date anything was reported is not — that is a dead campaign or a platform that has not caught up, and mureo cannot tell which, so those days are left out of the evaluation entirely. Filling to the requested range end instead turned a one-day reporting lag into a CRITICAL “100% below baseline” on every healthy campaign, at any hour of the day, and no delivery_collapse_consecutive_days setting closed it — a two-day lag simply produced days_at_collapse=2.

    One precondition on rows. Using the whole report as the bracket assumes every campaign in a single call was fetched together and finalises at the same time. mureo’s own Google and Meta clients issue one account-wide query per call, so they satisfy it by construction. An agent assembling rows itself may not: rows stitched from several fetches, or a connector whose campaigns finalise at different times, make the fastest campaign’s latest date the evidence, and a slower but perfectly healthy campaign gets zero-filled up to it and reported as collapsed. Two rules follow:

    • Pass all campaigns from one fetch in a single call. Do not mix rows retrieved at different times.
    • When you cannot guarantee that, set reported_through to the oldest per-campaign last date you trust. It costs a day or two of recency and never hides a real collapse. Do not pass the end of the range you requested — that asserts coverage the platform never confirmed, which is the bug above.

Two blind spots follow from that rule, both listed in the diagnosis limitations as well:

  • A campaign with no rows anywhere in the window is invisible. With no first row there is no series to reconcile, and inventing one would fabricate the baseline. Widen the window, or check the platform UI.
  • When every campaign stops reporting on the same day, no signal fires. Nothing proves those days were covered, and a total account outage is indistinguishable from a platform-side reporting failure. This one is reported rather than hidden: unreported_days climbs, and a value that keeps growing across runs is a finding in its own right — treat it as Action needed and check the account directly.

Thresholds live in STRATEGY.md ## Guardrails (all optional; a malformed or out-of-range value drops that one rule and keeps the default):

## Guardrails
- delivery_collapse_drop_pct: 90 # % below baseline that counts as a collapse
- delivery_collapse_consecutive_days: 1 # complete days required before alerting
- delivery_collapse_min_baseline_impressions: 1000 # ignore campaigns below this daily volume
- delivery_collapse_baseline_days: 28 # trailing window the median is taken from
- delivery_collapse_min_baseline_days: 14 # minimum history before the detector speaks
- delivery_collapse_min_same_weekday_samples: 2 # below this, fall back to the all-day median

What the diagnosis cannot answer. analysis_delivery_collapse_diagnose returns most_likely_cause: null and confidence: "undetermined" unless supplied evidence actually implicates a step — seven passing checks is an honest “undetermined”, not a diagnosis. Every response carries unresolved (steps nobody could check, and a note when there is no change in the pre-cliff window) and a standing limitations list:

  • Serving-side suppression — the platform choosing not to enter a campaign into auctions — is not exposed by any read API mureo has, on any platform.
  • No supported platform exposes billing state through an API mureo integrates.
  • Learning-phase internals (Google bid-strategy learning, Meta ad-set learning) are not readable; a learning reset is inferred from a change event, never observed.
  • Change feeds reaching mureo are incomplete: Google Ads change history omits system-initiated changes and retains ~30 days; Meta publishes an account activity log but mureo does not fetch it yet, so Meta changes reach the timeline only via action_log; and manual work reaches action_log only if it was imported. No change in the window is weak evidence, not exoneration — and on Meta the gap is mureo’s, not the platform’s.
  • Several campaigns collapsing on the same day is reported as a correlation only.

next_checks names the mureo tool for each open step on platforms that have one, and an empty string where mureo has no tool at all (billing everywhere; bid competitiveness and learning state on Meta) rather than inventing one. | analysis_exclusion_impact_preview | Size a bulk exclusion / block / negative-keyword batch before applying it: what share of the recent window’s impressions, clicks, cost and conversions the excluded entities carried, incrementally and cumulatively. Returns would_block from the same rule the dispatcher enforces. | either tool (+ arguments) or excluded_entities | | analysis_tracking_consistency_check | Audit final-URL tracking parameters across ad records from any platform. Returns findings with severity, delivery_state (served / not_served / unknown), the ad ids and the evidence — a utm scheme belonging to exactly one other campaign, one landing page under two schemes, a parameter the rest of the campaign carries, and violations of a ## Tracking Convention declared in STRATEGY.md. Pass planned_ads to pre-flight ads before creating them. | ads |

Applying an exclusion batch without knowing its size is how a Display campaign goes to zero impressions. This tool answers “how much of my current delivery does this remove”, from the account’s own recent performance — never a platform reach estimator.

Two calling conventions:

  • tool + arguments — the exact call you are about to make. mureo reads the excluded entities out of the arguments and fetches the matching report for that scope.
  • excluded_entities + delivery_records — you supply both sides. This form reaches no platform API, so a platform mureo does not model is still auditable whenever you can pull its own report.

window_days defaults to STRATEGY.md’s exclusion_impact_window_days (else 30). standing_exclusions is optional; omit it (rather than passing []) when the standing set is unknown — an empty list means “there are none”, and the cumulative figure is withheld rather than understated when it is unknown.

Coverage is measured, partial (some entity kinds are structurally unattributable on that basis) or unknown. unknown never means “no impact”, and incremental is null rather than a row of zeroes. A window that served nothing reports share_pct: null, not 0.

Per-surface attribution:

SurfaceDelivery sourceAttributable
google_ads_negative_placements_addgroup_placement_view over the windowYes for website / mobile_application; mobile_app_category is not a placement that serves, so a mixed batch reports partial
google_ads_negative_keywords_add / _add_to_ad_groupsearch_term_view over the window, matched per EXACT / PHRASE / BROADYes. Negative keywords do not match close variants and neither does the estimate, so it is a lower bound
meta_ads_excluded_placements_setNo. No insights breakdown attributes past delivery to publisher categories, publisher block lists or brand-safety content types. Reported unknown
Plugin / bridged surfaces (Yahoo, LINE, SmartNews, LOGLY, Amazon)Whatever the provider registers via register_exclusion_surface, else caller-supplied delivery_recordsProvider-declared

Cumulative tightening. cumulative is the share attributable to the whole standing exclusion set once this batch lands, which is what catches a fortnight of individually-small passes: an entity excluded a week ago still carries its pre-exclusion impressions inside a 30-day window. Its limit is the window — an exclusion older than the window contributed nothing to it and is invisible — so the cumulative figure is a lower bound. It is withheld (null with a reason) for an ad-group-level Google placement write, because campaign-level exclusions also cover that ad group and are not reachable from the call’s arguments, and for google_ads_negative_keywords_add_to_ad_group, because Google Ads exposes no ad-group-level negative keyword listing.

An inert rule says so. Because the cumulative figure is withheld on those scopes, max_cumulative_delivery_share_removed_pct enforces nothing there — and that is the scope the motivating incident happened at. Pair it with max_delivery_share_removed_pct, which is per-batch and needs no standing list. When a rule the operator wrote could not be evaluated for a call, mureo names it rather than letting it pass silently: unevaluated_rules in this tool’s response, and a NOT ENFORCED on this call: line (with the backstop to add) in the notice appended to the exclusion’s own result.

Enforcement. The three STRATEGY.md ## Guardrails keys max_delivery_share_removed_pct, max_cumulative_delivery_share_removed_pct and block_exclusions_without_impact_data are enforced in the dispatcher before an exclusion tool runs. They are not in StrategyPolicyGate: the check needs one awaited platform read and the PolicyGate v1 ABI is synchronous by design and must stay pure and fast. With none of them written the check does no I/O at all and behaviour is unchanged. MUREO_DISABLE_EXCLUSION_PREFLIGHT=1 turns it off entirely.

had_prior_spend (default true) suppresses the zero-spend alert for fresh campaigns. min_baseline_entries (default 7) controls how many history entries are required before a baseline is built; below this, baseline is null and only zero-spend is evaluated. Numeric fields accept int / float / numeric-string and reject "N/A" or booleans. state_file is sandboxed the same way as for the rollback tools. A parseable-but-corrupt STATE.json produces a baseline_warning in the response without silencing live zero-spend detection.

analysis_tracking_consistency_check takes ad records (ad_id, campaign_id, final_urls, platform, optional campaign_name / status / impressions) that the caller assembles from google_ads_ads_list, meta_ads_ads_list, a plugin’s own list tool or a bridged MCP — so a platform mureo cannot fetch ads for is still auditable whenever the agent can list them. Ads are only compared with ads carrying the same platform value. impressions grades the finding: >0 makes it a data-integrity incident (critical), 0 a cheap fix (high), and omitting it leaves delivery_state: unknown with a note saying the severity may be understated — omitted is not 0. Ads whose URL could not be read are listed in ads_without_readable_url rather than reported clean. See docs/tracking-consistency.md for the full list of what is and is not detectable.

Read and write the strategy context files (STRATEGY.md, STATE.json) directly through the MCP server. These tools exist for hosts that have no direct filesystem access (Claude Desktop chat, web, remote MCP); every write is atomic and validated before it replaces the file.

Both read tools carry a server_now field — the server’s clock as ISO 8601 with a UTC offset (e.g. 2026-07-28T10:12:33+09:00). It is the authoritative current date for an agent that cannot run date (headless / Bash-less hosts): every other date in the document is history. server_now is a response field only and is never persisted — the parser ignores unknown top-level keys, so a copy echoed back into STATE.json is dropped by the next mureo write. Both also name which file the answer came from — path (the file read) and the runtime’s workspace_id, plus notices when the runtime supplied any (omitted when it did not) — so a “the last check was N days ago” verdict stays checkable when a scheduled run and an operator terminal sit in different workspaces; these three are response fields only and are never persisted either. A notice means the runtime does not consider this session to be on the workspace it should be on; workspace-bound skills (/daily-check) stop on it rather than proceed.

ToolDescriptionRequired Parameters
mureo_strategy_getRead STRATEGY.md — raw markdown plus an exists flag (empty markdown when absent), server_now, the path it read and the runtime workspace_id (and notices when the runtime has any)(none)
mureo_strategy_setAtomically replace STRATEGY.md (parsed for well-formedness before writing)markdown
mureo_state_getRead STATE.json as a parsed v2 document (version, platforms, campaigns, action_log, decisions) plus server_now, the path it read and the runtime workspace_id (and notices when the runtime has any). Optional action_log (all / pending / none) and decisions (all / none) scope the two trails independently to cut context cost. Unless auto_evaluate is false, the call also closes every past-due observation the document itself decides and reports them in auto_evaluations / auto_evaluation_skipped (see below)(none)
mureo_state_action_log_appendAtomically append a single action_log entry for later evaluation (timestamp is stamped server-side). The entry may carry display_title (≤40 chars) and display_summary (≤120) — the one line the dashboard shows for it, refused rather than truncated when over. They add a rendering and replace nothing: summary is still written as fully as the next agent needs. session_id and client are stamped by the server from the writing session. reason (≤500) is yours to set: unlike every other mutating tool this one takes no call-level reason, because it records a change rather than making one, so the entry’s field is the only place the rationale can goentry
mureo_state_upsert_campaignUpsert a CampaignSnapshot (with optional performance metrics) into STATE.jsoncampaign
mureo_state_report_setPersist a structured report summary for the read-only dashboard. report names the kind, one per skill that writes one — daily, weekly, monthly, goal, audience, experiment, fatigue, pacing, tracking. The structure is enforced: headline figures belong in totals as raw numbers (a canonical metric carrying a string is refused — it renders as nothing), each finding is its own flags entry, and narrative is capped at 400 characters, a longer one being refused rather than truncated. Reports already on disk are untouched — the bound applies to new writes. Every version written is also appended to history/reports/<kind>.jsonl, so the one it replaces is still readablereport, summary
mureo_state_display_setWrite the display contract — the small, write-guarded surface the dashboard renders, separate from everything else in STATE.json. Five sections: nav_message (one operator line, ≤80 chars), highlights (≤3 {tone, text} chips, tone good/watch/bad, text ≤60), proposals ({title ≤30, body ≤80, status proposed/done, date}), breakdown.campaigns / .adgroups (rows of {name, spend, mcpa, target_cpa, state, note ≤40}, state from a closed set), and stated_values ({label ≤24, value} where the value is a raw number or a string ≤12 chars — prose is refused). Over a bound or outside a vocabulary the write is refused, never truncated. source (the writing skill, ≤24) is required alongside any section and generated_at is stamped server-side, so a screen always says who drew it and when. The whole section is replaced by what the call states, and a call that states nothing clears it — so a second writer the same day must read the current contract first and carry over the other skill’s still-live proposals, and nothing else. The KPI funnel and the daily chart are deliberately not writable: mureo computes both from the stored totals(none, but source once any section is stated)
mureo_state_platform_metrics_setSet a platform-level metric rollup (feeds the YESTERDAY / LAST_7_DAYS / LAST_30_DAYS dashboard toggle). The window vocabulary is closed — those three tokens only, as metrics_period and as periods keys; any other window is refused rather than stored, and never rounded onto a neighbouring oneplatform, account_id
mureo_state_platform_daily_setAdd day-grain history to a platform, keyed by calendar date (YYYY-MM-DD) — the trend line and day-over-day delta the window rollups cannot hold, since each of those keeps one value and every collection overwrites it. Merged per date key, so a day already stored survives the next write. Only complete PAST days are accepted (today is still being spent into), a day you did not collect is omitted rather than written as zeros, and the most recent 35 days are kept — the days that cap trims are archived under history/daily/<YYYY-MM>.json rather than dropped (see History). Optional as_of_date states today in the ad account’s timezone, so a UTC host writing an Asia/Tokyo account in the small hours is not told a finished day is still running; omit it and the server’s own today is used. It is checked against the server’s date and refused if more than 2 days ahead of it — no timezone is further ahead, and a mis-inferred year must not turn unreached dates into complete historyplatform, account_id, days
mureo_state_platform_not_collected_setRecord why a platform could not be collected — or clear that note once it can. Omit reason to clear, and do so on the next successful collection: nothing else retires it. The stored figures are never touched (they were not updated, not proven wrong), and last_synced_at is not re-stampedplatform, account_id
mureo_state_workspace_not_collected_setRecord why the whole workspace could not be collected — the run that died before any platform was reached — or clear that note once one succeeds. Takes no platform and no account_id: those are exactly what such a failure could not resolve. Nothing else in the document is touched, including any per-platform note, and last_synced_at is not re-stamped(none)
mureo_state_set_conversion_eventsDeclare which Meta Insights action_type rows count as this account’s conversionsplatform, account_id
mureo_outcome_evaluateDeterministically score a logged action’s outcome (improved / regressed / inconclusive) from before/after metricsbefore, after
mureo_decision_recordRecord a decision — a proposal, or the operator’s answer to one — so the reasoning survives the session. status is proposed / adopted / rejected / deferred, title (≤120) says what is being decided, rationale (≤2000) says why, and metrics holds the figures it was judged on as they stood then (≤20 keys, each value a string / number / boolean / null — a nested object is refused). The section is append-only: a status change is a NEW record naming the first in supersedes, never an edit, which is what keeps “we proposed this on the 4th and it was turned down” recoverable. related_actions names the action_log indices the decision produced and batch_id the change set it concerns — a closed batch is fine, unlike an action_log append, because the verdict on a bulk pass is normally recorded after it finished. decision_id and recorded_at are minted server-side. Over a bound the write is refused, never truncated. display.proposals is the screen (replaced whole on every dashboard write); this is the recordstatus, title, rationale
mureo_history_queryRead the past — one bounded query over the four trails, for any question that reaches beyond what STATE.json currently holds. sources picks from action_log, journal, daily and reports (default the first two); since / until bound it by UTC date (with no since, daily reads back 12 months and says so in its window); platform, campaign_id, the entity_type + entity_id pair and batch_id narrow it; tool, outcome, mutations_only and failures_only apply to the journal source only. limit (default 50, max 200) applies per source and each section reports whether more matched than it returned. Read-only, so it takes no reason. See History queries below(none)

Automatic observation closure (#758, phase 5)

Section titled “Automatic observation closure (#758, phase 5)”

An action_log entry with an observation_due stays open until somebody appends an evaluation_of record for it. mureo_outcome_evaluate is pure — it writes nothing — so that somebody was always the agent, and when it forgot, the entry was re-evaluated on every run and the pending set grew without bound. For the common case the verdict is already determined by what STATE.json holds, so mureo_state_get now computes it and closes the entry itself, before it reads the document — the response you get already contains the closure record rather than the entry it closes, and a pending scope in the same call no longer lists it.

An entry is closed when all of the following hold. Each rule is checked in this order and the first one that fails is the reported reason, so the answer is deterministic:

Skip reasonWhat it means
external_originorigin is set: mureo only observed this change. It did not make it and did not record the prior value, so the outcome is not mureo’s to state
no_baselineno metrics_at_action — nothing to compare against
no_campaign_idthe entry names no campaign, so no current metrics can be located
platform_not_in_stateplatforms[<platform>] is absent
campaign_not_in_statethat platform holds no snapshot for the campaign
no_current_metricsthe snapshot carries no metrics
unparseable_due_dateobservation_due is not an ISO date, so “has the window closed?” cannot be answered
current_metrics_undatedthe snapshot’s metrics.fetched_at is missing or unreadable. There is deliberately no fallback to platform.last_synced_at: a platform-level sync stamp says nothing about when this campaign’s figures were read
current_metrics_predate_windowthe metrics were collected before the due date, so they cover a period that overlaps the observation window instead of following it, and would score the wrong days
period_mismatchboth sides state a period and the two differ (LAST_7_DAYS against LAST_30_DAYS, compared case- and whitespace-insensitively). A 7-day baseline scored against a 30-day snapshot reads as a large movement in every volume metric and is nothing but the window changing. When either side states no period mureo does not skip — the manual mureo_outcome_evaluate path has exactly the same limit, since it also compares whatever two maps it is handed
no_comparable_metricthe baseline and the current snapshot share no numeric key, so evaluate_outcome scored nothing
write_denied: <text>the host’s policy gates refuse an action_log append — see below

The closure entry is the same record a diligent agent would have written: action: "outcome_evaluated", evaluation_of = the source entry’s index, the same platform / campaign_id / ad_id / entity_type / entity_id, summary = evaluate_outcome’s summary, metrics_at_action = the whole campaign snapshot it was scored against — period and fetched_at included, so the record says which days produced the verdict; only the scoring call drops the non-metric bookkeeping keys (period, fetched_at, result_indicator) — and reason = Automatic observation closure (<verdict>): <summary>, scrubbed for secret-shaped substrings at the same boundary every other reason crosses and bounded by slicing (it is machine-built, so there is no caller holding a sentence to shorten). session_id and client are stamped from the writing session exactly as on any other entry, so the closure joins the JOURNAL.jsonl records of the call that produced it. It carries no batch_id: an automatic record joining whichever batch happens to be open would grow a change set nobody dispatched. Every closure of one pass is appended in one locked write, and last_synced_at is not re-stamped — appending an action is not a sync. INCONCLUSIVE is a real verdict (“moved less than the ±10% noise band”) and closes the observation like any other; only “nothing could be scored at all” leaves it open.

The freshness rule trusts fetched_at as the collector wrote it — mureo cannot verify when a platform’s figures were actually read, so a collector that stamps a wrong date produces a wrong closure. That is the same trust the dashboard already places in the snapshot it renders; nothing here widens it, but nothing here checks it either.

The write asks the policy gates the same question a mureo_state_action_log_append call would: a host that runs a read-only gate (mureo-agency’s, for example) gets auto_evaluation_skipped entries reading write_denied: <the gate's own sentence> and STATE.json is left untouched. A read tool must not do what the host forbids a write tool to do. Nothing is written when nothing is due either — the lock is not even taken — so a repeated mureo_state_get leaves the file byte-identical.

auto_evaluate: false turns the whole pass off; both response keys are then omitted rather than empty, because “mureo closed nothing” and “mureo did not look” are different answers. When the pass does run, both keys are always present. If it fails for any reason the response carries auto_evaluation_error ("<ExceptionType>: <message>", logged server-side with its traceback) and the document is returned unchanged — mureo_state_get is the call every skill starts from and must never fail on this.

In the journal the call stays mutating: false. That is the honest classification of what the agent asked for, and the trail is complete without changing it: each closure entry names the session that wrote it and says why.

The decisions section (#758, phase 3). Decision records live in STATE.json under decisions, beside action_log and batches — not inside display. The distinction is the one that makes the section worth having: display.proposals is a screen, replaced whole by whichever skill writes the dashboard last, so a proposal written on Monday is gone by Tuesday; decisions is a history, appended to and never rewritten. A record carries its own decision_id, the server’s recorded_at, status, title, rationale, the metrics it was judged on, and the joins back to the rest of the document (related_actions into action_log, batch_id, supersedes). session_id and client are stamped from the writing session, exactly as on an action_log entry, so a decision and the changes it produced carry the same session id. Old STATE.json files gain no key: the section is emitted only when it holds something. mureo_state_get returns it when present, and its decisions argument scopes it: all (the default) returns every record, none omits the section and leaves decisions_total plus a decisions_scope marker behind, so an omitted trail is never read as an empty one. The count is emitted in both modes whenever the document has any decisions — “there are 14 of these” is what tells an agent to go and look before it re-proposes something. The switch is independent of action_log: dropping the log to save context must not silently drop the reasoning behind it. title, rationale and every string metrics value are scrubbed for secret-shaped substrings on the way in and stripped of control characters, by the same scrub_text boundary an action_log reason crosses — a model that quotes the failing request into its reasoning must not leak a key into a file the operator commits. The dashboard does not render the section yet — that is a follow-up.

Discover and invoke the analytics modules registered for each platform (built-in google_ads / meta_ads, plus any supplied by provider plugins via the mureo.analytics entry-point group). Both tools are read-only diagnostics and route through the standard analysis dispatcher (#440).

ToolDescriptionRequired Parameters
mureo_analytics_modules_listList analytics modules per platform and the capabilities each advertises (detect_anomalies, diagnose_performance, audit_creative, analyze_budget_efficiency, detect_delivery_collapse)(none)
mureo_analytics_runRun one capability of a platform’s analytics module and return its structured result; degrades to a structured status (no_analytics_module / capability_not_available / error) instead of failing the workflowplatform, capability, account_id

Retrieve accumulated practitioner know-how before drawing diagnostic conclusions — the operator’s own /learn history and, optionally, federated retrieval against external advisor MCP servers (see insight-federation.md).

ToolDescriptionRequired Parameters
mureo_learning_insights_getLoad every insight previously saved via /learn as raw Markdown(none)
mureo_consult_advisorQuery external advisor MCP servers (vector search) enriched with local campaign state; advisor responses are treated as untrusted external contentquestion

Every automated-bidding system has a learning period, and a change that restarts it costs days of delivery — most damagingly while someone is troubleshooting a collapsed campaign, because troubleshooting means many changes in a row. This tool answers, before the change, whether it restarts learning and whether the campaign is already re-learning.

ToolDescriptionRequired Parameters
mureo_learning_reset_preflightClassify a pending change against the campaign’s learning period: reset_risk (with the first-party source it rests on), learning_state, and whether ## Guardrails would refuse ittool_name

Read-only: it calls no platform API and changes nothing.

Three surfaces, of deliberately different strength. MCP has no interposed confirmation step — mureo either runs a tool call or refuses it — so these are not interchangeable:

SurfaceWhenStrength
## Guardrails block_learning_resets / block_learning_resets_during_incidentbefore dispatchhard — the call is refused by StrategyPolicyGate, before any API call

block_learning_resets is an account-wide freeze: it refuses every reset-triggering change, with or without an identifiable campaign. block_learning_resets_during_incident is narrower by name and in fact — it refuses only a change that identifies a campaign which is not positively known to be out of a learning period. An unknown state on an identified campaign is refused (fail-closed); a change that identifies no campaign at all (google_ads_conversions_* is account-level, google_ads_budget_update is keyed on a budget_id) has no subject and is not refused, or the rule would permanently block editing a conversion action with no relation to any incident. | mureo_learning_reset_preflight | before the change, when the agent calls it | advisory — as strong as the agent’s compliance | | A notice appended to a reset-triggering call’s own result | after that call | records the reset so the next change in the sequence is not made blind |

Per-platform coverage. Reset triggers are sourced from first-party documentation only; where mureo has no such source it reports unknown and never no_reset, because a false “this resets nothing” turns a missing warning into implied approval.

PlatformLearning state readable by mureo?Reset triggers known?
Google AdsYesbidding_details.bidding_strategy_system_status on the campaign’s STATE.json snapshotYes — Google’s own LEARNING_* enum members (BiddingStrategySystemStatus)
Meta AdsNo — Meta exposes learning_stage_info on the ad set; mureo’s client does not request it and STATE.json is campaign-levelNo — Meta documents that “significant edits” restart the phase without enumerating them
Amazon Ads (official-MCP bridge)No — no learning-state read exists on the bridged tool surfaceNo
Yahoo / LINE / SmartNews / LOGLY (plugins)No — unless the plugin registers rulesNo — unless the plugin registers rules

A plugin or bridge advertises its own platform’s rules through mureo.policy.learning_rules.register_platform_learning_rules, the same registry pattern the budget/bid declarations use.

What a platform has no check for — only a wrong assumption. The table above is the data half: facts mureo can verify. The prose half is mureo.policy.platform_model.register_platform_model (#648) — one capped paragraph stating how a platform selects and prices delivery, and what it therefore does not have. It is rendered into the server’s MCP instructions (the initialize response), so it is read before any tool call and does not depend on a skill description matching; it appears only when this server serves tools for that platform. Every model carries the same first-party Evidence record, and mureo core registers none of its own — a platform with no registered model contributes nothing rather than a default. Registration is first-wins and a model is rendered only where the provider that registered it actually contributed the matching tools, so a plugin can state how its own platform works but never how another’s does; a block that lost statements to the length cap says so in the block itself. See plugin-authoring.md §3.

The state read is local by design. A policy gate runs on every tool call and must not make network calls, so the learning state comes from STATE.json rather than from the platform. Keep it fresh (google_ads_campaigns_get / google_ads_campaigns_diagnosemureo_state_upsert_campaign); a missing observation is reported unknown, never steady.

Generate creator-quality ad creatives — text-free key visuals plus copy composed over them into per-format banners. Image generation runs through configured providers; banner composition renders HTML/CSS with headless Chromium and requires the creative extra (pip install 'mureo[creative]'). See creative-studio.md.

ToolDescriptionRequired Parameters
creative_studio_providers_listList image-generation providers, whether each has an API key configured, its capabilities, and model ids(none)
creative_studio_generate_visualGenerate text-free key-visual PNGs from a visual-only prompt (a hard no-text constraint is appended)prompt
creative_studio_brand_kit_getReturn the loaded brand kit (colours, fonts, logo, clear-space) or tasteful defaults(none)
creative_studio_edit_visualRefine an existing key visual through a provider’s edit path (art-direction loop)path, instruction
creative_studio_composeComposite ad copy + brand kit over a key visual into per-format banner PNGsvisual_path, headline, cta

Amazon Ads is not a mureo-native tool family, so it has no table above. mureo bridges the official Amazon Ads MCP: the tools it exposes come from the operator’s own local manifest (amazon_tools.json, beside the credentials file — ~/.mureo/amazon_tools.json by default, written by mureo amazon refresh-manifest), and their surface is defined by Amazon, not by mureo. That means the exact tool list cannot be enumerated here — it is whatever your account’s manifest holds, and it changes when Amazon changes it.

What is stable:

  • Names are Amazon’s own — mureo does not remap official-MCP tool names. They are namespaced by Amazon’s own taxonomy, e.g. campaign_management-* and account_management-*.
  • Read at start, pure. The bridge reads the manifest file only — no credentials, no network — so a missing or malformed manifest means “no Amazon tools”, never a startup failure. Re-run refresh-manifest and restart the server after Amazon’s surface changes.
  • Same safety layer as a plugin tool. Amazon calls ride the plugin dispatch branch below: audited to the append-only jsonl log (secrets scrubbed), throttled, strategy-gated, and — for successful mutations — promoted into STATE.json action_log under platform="plugin:mureo-amazon-ads-bridge:amazon_ads" with an observation window.
  • Env gate. MUREO_DISABLE_AMAZON_ADS=1 suppresses the bridged Amazon family the same way MUREO_DISABLE_GOOGLE_ADS / MUREO_DISABLE_META_ADS suppress their built-in families.

Credential setup (configure UI card, AMAZON_ADS_* env vars, or the amazon_ads section of ~/.mureo/credentials.json), access-token minting and auto-refresh, and the honest scope of what Amazon support does not include are all covered in amazon-ads.md.

Plugin-Provided Tools (third-party providers)

Section titled “Plugin-Provided Tools (third-party providers)”

Beyond the built-in platforms above, the server also exposes tools from third-party provider plugins discovered via the mureo.providers entry-point group. A plugin opts in by implementing the MCPToolProvider Protocol (mcp_tools() + async handle_mcp_tool()); see plugin-authoring.md §3. The in-tree Amazon Ads bridge implements the same Protocol, so everything below applies to it too.

Server behaviour:

  • Additive. Plugin tools are appended after all built-in tools. With no third-party plugins installed, the tool list is identical to before — built-in behaviour is unchanged.
  • Built-ins win on name collision. A plugin tool whose name matches any built-in tool is dropped (a PluginToolWarning is emitted); the built-in keeps the name. Plugin authors should namespace tool names with their provider name (e.g. acme_ads_list_campaigns).
  • First plugin wins when two plugins contribute the same tool name.
  • Fault-isolated. A plugin that fails to construct, whose mcp_tools() raises, or whose handle_mcp_tool is not async, is skipped with a PluginToolWarning — it can never crash the server or block other plugins. Discovery itself failing wholesale yields zero plugin tools rather than a startup error.
  • Discovered once at server start, like the env-var gates below.

Plugin tools obey the same MUREO_DISABLE_* reasoning only insofar as the plugin chooses; the disable env vars gate the built-in families, not third-party plugins. The in-tree Amazon Ads bridge rides this same dispatch branch but is mureo’s own code, so it does have a gate: MUREO_DISABLE_AMAZON_ADS=1.

Every call that enters the dispatcher leaves exactly one append-only JSON line — whatever the family (built-in or plugin) and whatever the outcome. A refused, denied or failed call is recorded exactly like a successful one: “what did the agent actually try” is the question this file answers, and a trail of successes cannot answer it.

FieldMeaning
vRecord schema version (2 since #758 phase 6; 1 lines are read unchanged)
tsUTC ISO-8601 timestamp, second precision
sessionRandom id minted once per server process
client"<MCP client name>/<version>", or null when the client did not report one
mureoThe mureo version that served the call
workspace_idThe bound workspace identifier
toolTool name as dispatched
familygoogle_ads, meta_ads, search_console, rollback, batch, change_import, analysis, mureo_context, analytics, learning, learning_preflight, creative_studio, plugin, unknown
sourcePlugin distribution — present only when family is plugin
mutatingWhether the call was classified as a mutation
argsThe call’s arguments, masked (secret-shaped keys → ***, long strings truncated)
rationaleWHY the agent made the call — the reason parameter it passed. Scrubbed. Absent when none was given
outcomeok / platform_error / exception / denied / refused / invalid_args
reasonWhy a non-ok outcome happened; scrubbed and capped at 512 chars. Absent for ok
duration_msWall-clock milliseconds around gate + validation + preflight + handler
batch_idThe batch open at the time, or null
rollbacktrue only when the call was a rollback’s reversal leg
prevSHA-256 of the previous physical line — "" for the very first line ever written
hSHA-256 of this record serialised without h; always the last key

Every mutating tool accepts reason. One or two sentences naming the evidence and the expected effect — mureo’s own parameter, not the handler’s: it is split off before the policy gates, so no tool ever receives it and a call that is denied or refused still records why it was attempted. It lands in two places, the journal’s rationale above and the reason field of whatever action_log entry the call produces, which is what lets a later session read why a change was made rather than only that it was. Over 500 characters it is refused, never truncated; whatever survives is scrubbed by the same rules as the rest of the trail, so a rationale that quotes a failing request cannot leak a key into either file.

Three tools that write STATE.json rather than an ad account take it too — mureo_batch_begin, mureo_state_set_conversion_events and mureo_external_changes_import — even though they are not classified as mutations. Three do not:

  • mureo_state_action_log_append records a change instead of making one, so the rationale goes in its entry.reason field, where it describes the change rather than the act of writing it down;
  • mureo_state_platform_not_collected_set and mureo_state_workspace_not_collected_set keep a reason of their own, where the string is the persisted “why was this not collected” note.

An observed entry (origin: "external") never inherits the importing call’s rationale: mureo did not make that change and has no standing to say why it was made.

What is never written. Result bodies and credentials. The journal stores the masked arguments and the outcome — never what a tool returned, and never a token: args go through the same masker and reason through the same scrubber as the plugin audit log.

Where the file lives. Beside STATE.json / STRATEGY.md when the server is bound to a real workspace directory (<workspace>/JOURNAL.jsonl), so the record travels with the workspace. A directory holding neither is not a workspace, and mureo will not drop files in it: those calls go to ~/.mureo/journal.jsonl.

Opt-out. Set MUREO_DISABLE_JOURNAL=1 (exact string 1) in the server’s environment to write nothing at all.

Every record ends with prev and h (above), so the file is a chain: each line names the exact bytes of the line before it, and names itself. An edited line fails its own h; a removed or spliced line fails the prev of the line after it. RECORD_VERSION is therefore 2 — a v: 1 line, written before the chain existed, carries neither key, is reported as unchained and is never treated as tampering.

What the chain does not prove: tail truncation. Every link points BACKWARDS, so deleting the last N lines leaves a shorter chain that is still internally consistent. Detecting that needs an anchor kept where the writer cannot reach it, which a local-first tool does not have. Everything before the last line is covered; that one gap is stated here rather than left for someone to discover.

A journal that has reached 32 MiB is renamed out of the way before the next line is written — JOURNAL.20260919T013000Z.jsonl, the UTC instant it was retired at, in the same directory — and a fresh JOURNAL.jsonl is started. Set MUREO_JOURNAL_MAX_BYTES to another positive number of bytes to change the ceiling (anything else is refused with one warning and the default is used). mureo never deletes a rotated file: the bound is on the size of one file, never on how much of the record is kept.

The chain continues across a rotation: the first line of the new file points at the last line of the retired one, so the files verify as one sequence — which also means that deleting or moving a rotated file by hand makes --verify report prev_mismatch at line 1 of the oldest file still present, since nothing distinguishes a file you pruned from one somebody else removed (mureo never deletes one itself). Readers follow the whole set — mureo journal --all, mureo journal --verify and the journal source of mureo_history_query all walk the rotated files with the live one.

Read it with the CLI — see cli.md:

Terminal window
mureo journal # the last 50 calls, as a table
mureo journal --failures --last 20 # the last 20 refused / failed calls
mureo journal --tool meta_ads_campaigns_update --json
mureo journal --all # include the rotated files
mureo journal --verify # check the chain; exit 0 ok / 1 broken

STATE.json stays bounded on purpose — it is read whole and re-rendered on every mutation — so two of its sections drop what they replace. Since #758 phase 4 the write archives that first, into a history/ directory beside STATE.json, on the same lock:

WhatWhereRule
The daily days past the 35-day retention windowhistory/daily/<YYYY-MM>.json — one file per calendar month, every platform in itmureo_state_platform_daily_set archives the days it is about to trim before the trimmed document is written. Re-filing a day replaces it, so a re-run adds nothing
Every version of every report kindhistory/reports/<kind>.jsonl — append-only, one JSON line per versionmureo_state_report_set appends the version it is writing, including the one that stays in STATE.json, so the ledger alone is the complete series. Each line carries recorded_at, the writing session_id and the MCP client when one is known

Neither changes what STATE.json holds or what the dashboard renders: the document still keeps 35 days and the latest report, and nothing reads the archives to draw a screen. A failing archive fails the write — STATE.json is left exactly as it was — because an archive that can be skipped is not an archive. A month file that does not parse (or that declares a version this mureo cannot merge) is refused rather than overwritten, and the refusal names the remedy: move that file aside to resume archiving. Each month file is merged under its own sidecar lock, so two writers landing in the same month cannot lose each other’s days. The files are owner-only (0600) and live in the workspace, like JOURNAL.jsonl.

Rotation (#758 phase 6). A report ledger that has reached the same 32 MiB ceiling as the journal (MUREO_JOURNAL_MAX_BYTES) is rotated away — history/reports/daily.20260919T013000Z.jsonl — inside the lock, before the line that filled it is appended, and read_report_history reads the rotated files with the live one, oldest first, so limit still means “the newest versions”. The monthly daily archives need no bound: they are one file per calendar month and each is replaced in place. There is no hash chain on the history files — they are derived data, re-derivable from what the write path had, while STATE.json and the journal are the records of truth.

mureo_history_query (#758, phase 4b) is the read side of all of it: one tool, four sources, one set of filters. Until it existed each trail had its own door — action_log and the daily window only inside a whole mureo_state_get, the journal only through the mureo journal CLI, the archives through nothing at all — so an agent on a host with no shell could not ask “what did we change on this campaign in July” and one that could had to read a whole document to find out.

SourceWhat it returnsNotes
action_logThe curated record of changes, each entry as mureo_state_get emits it plus its index in the FULL logThat index is what related_actions and evaluation_of refer to, so an entry returned here can be named by a later write
journalEvery tool call and its outcome — including the denied, refused, invalid_args and failed ones that action_log correctly never holdsBounded to the last 20 000 lines of the journal, reported as scanned_lines. The rotated files count towards the same bound, spent newest file first, so the cost of a query never grows with the age of the workspace. The path is reported even when there is no journal
dailyThe day-grain series for one platform: the archived months and the days still inside the document, merged into oneRequires platform. A day held in both is the document’s — that is the copy the dashboard renders. With no since, read back 12 months from until (or today) instead of over every month the account ever had; the section’s window reports the since / until it answered from and whether that floor was defaulted. A month file that could not be read is named in skipped_files rather than left as a silent gap
reportsEvery version ever written of one report kind, oldest firstRequires kind, and a kind without the reports source is refused rather than ignored

limit (default 50, maximum 200) applies per source, the newest matches are the ones kept, and every section carries returned plus truncated — a partial answer says so rather than reading as a complete one. action_log also carries matched, the number before the limit was applied.

Filters: since / until are inclusive UTC dates (until before since is refused), and a record whose own date cannot be read is excluded by a dated query rather than guessed into the window. Omitting since leaves the action_log, journal and reports sources unbounded below (each has a scan cap of its own) but gives daily a 12-month floor, because the archive is one file per calendar month and a dateless question would otherwise open all of them. platform, campaign_id, the entity_type + entity_id pair (both or neither) and batch_id narrow every source that has the field; tool, outcome, mutations_only and failures_only are journal-onlyaction_log, daily and reports record no outcome and no mutating flag, so they ignore those four rather than returning nothing. On action_log the filters are exact — those fields are stored on the entry. On the journal they are best-effort: a journal record is a record of a CALL, so campaign_id / entity_id are matched against the call’s campaign_id, <entity_type>_id, entity_id or id argument, and a plugin:<dist>:<provider> platform matches on the distribution that served the tool (the journal records which distribution answered, not which of its providers).

A missing file is an answer, not an error: no STATE.json puts "state": "missing" on the action_log and daily sections rather than raising, and no journal returns no records with the path it looked for. The tool is read-only — its name ends in no mutating suffix, so no strategy reminder is appended and no call-level reason is injected into its schema.

Beyond individual MCP tools, mureo provides higher-level operational workflows via Claude Code slash commands. These commands orchestrate multiple MCP tools in sequence, guided by the strategy context defined in STRATEGY.md.

CommandPurpose
/onboardInteractive account setup, STRATEGY.md generation, STATE.json init
/daily-checkMode-aware daily health monitoring
/rescueEmergency performance rescue
/search-term-cleanupStrategy-aligned search term hygiene
/creative-refreshPersona/USP-driven ad copy refresh
/budget-rebalanceMode-guided budget reallocation
/competitive-scanAuction analysis with Market Context
/sync-stateManual STATE.json synchronization

Each command reads strategy context (Operation Mode, Persona, USP, Brand Voice, Market Context) from STRATEGY.md and campaign state from STATE.json, then selects and invokes the appropriate MCP tools. For example, /daily-check adapts its monitoring focus based on the current Operation Mode — an EFFICIENCY_STABILIZE mode prioritizes CPA and budget efficiency, while a GROWTH_SCALE mode focuses on impression share and conversion volume.

Operational skill definitions live under skills/ (daily-check, budget-rebalance, etc.). See strategy-context.md for the strategy file format and Operation Mode reference.

mureo is designed to work alongside other MCP servers in the same client session. For example, you can configure a GA4 MCP server next to mureo so that workflow commands like /daily-check and /budget-rebalance can incorporate analytics data into their analysis.

mureo’s workflow commands check for external tool availability opportunistically — if a GA4 or other MCP server responds, the agent uses that data; if not, the command proceeds with mureo’s own data. There is no hard dependency on any external MCP server.

For detailed setup instructions, supported platforms, and configuration examples, see integrations.md.

The Google Ads customer ID is a 10-digit number (e.g., "1234567890"). Dashes are automatically stripped.

The Meta Ads account ID must start with act_ (e.g., "act_1234567890").

  • status_filter: Filter by entity status ("ENABLED", "PAUSED", etc.)
  • period: Time range for reports ("LAST_7_DAYS", "LAST_30_DAYS" for Google Ads; "today", "yesterday", "last_7d", "last_30d" for Meta Ads)
  • limit: Maximum number of results to return (Meta Ads, default: 50)

The keywords parameter for google_ads_keywords_add and google_ads_negative_keywords_add is an array of objects:

{
"keywords": [
{"text": "running shoes", "match_type": "BROAD"},
{"text": "best running shoes", "match_type": "PHRASE"},
{"text": "nike running shoes", "match_type": "EXACT"}
]
}

match_type defaults to "BROAD" if omitted.

The google_ads_ads_create tool accepts headlines and descriptions arrays:

{
"customer_id": "1234567890",
"ad_group_id": "111222333",
"headlines": ["Buy Running Shoes", "Free Shipping", "Best Prices"],
"descriptions": ["Shop our collection of running shoes.", "Free returns on all orders."],
"final_url": "https://example.com/shoes"
}

Headlines: 3-15 items. Descriptions: 2-4 items.

To create a display campaign, pass channel_type: "DISPLAY" to google_ads_campaigns_create:

{
"customer_id": "1234567890",
"name": "Brand Display Campaign",
"channel_type": "DISPLAY",
"bidding_strategy": "MAXIMIZE_CONVERSIONS",
"budget_id": "555666777"
}

Then create an RDA via google_ads_ads_create_display. Local image file paths are uploaded automatically before the ad is created:

{
"customer_id": "1234567890",
"ad_group_id": "111222333",
"headlines": ["Run Faster", "Train Smarter"],
"long_headline": "The shoes that changed how athletes train",
"descriptions": ["Cushioning tested by Olympic runners.", "Free 30-day returns."],
"business_name": "Acme Athletics",
"marketing_image_paths": ["/path/to/marketing-1200x628.jpg"],
"square_marketing_image_paths": ["/path/to/square-1200x1200.jpg"],
"logo_image_paths": ["/path/to/logo.png"],
"final_url": "https://example.com/shoes"
}

Constraints (per the Google Ads API):

  • Headlines: 1-5 items, each ≤30 display width
  • Long headline: required, ≤90 display width
  • Descriptions: 1-5 items, each ≤90 display width
  • Business name: required, ≤25 display width
  • Marketing images (1.91:1): 1-15 files, 3+ recommended for delivery quality
  • Square marketing images (1:1): 1-15 files, 3+ recommended
  • Logo images: optional, up to 5
  • The target ad group must belong to a DISPLAY campaign (mureo verifies this before any upload)

If image upload fails partway through or the ad creation fails after all uploads succeed, an RDAUploadError is raised that includes the resource names of any orphaned uploaded assets so they can be cleaned up.

All tools return TextContent with JSON-serialized results. The JSON structure varies by tool but follows a consistent pattern:

[
{
"type": "text",
"text": "{\"campaigns\": [{\"id\": \"123\", \"name\": \"Brand\", ...}]}"
}
]

The text field contains a JSON string that your agent should parse.

An auth failure is returned as a result, not raised as an exception — but it is a structured, machine-readable outcome, not prose. Every platform uses the same envelope:

[
{
"type": "text",
"text": "{\"status\": \"auth_error\", \"auth_cause\": \"no_credentials\", \"detail\": \"Credentials not found. Set environment variable (META_ADS_ACCESS_TOKEN) or configure ~/.mureo/credentials.json.\"}"
}
]
FieldMeaning
statusAlways auth_error. This is the marker to branch on: mureo could not read this platform at all, so it produced no data for this call.
auth_causeno_credentials — nothing is configured for this platform. token_invalid — a credential exists and the platform rejected it (expired or revoked token, withdrawn permission). The two have different recovery actions.
detailThe operator-facing sentence: which environment variable to set, or what the platform said.

Both causes are produced centrally, so every platform behaves identically: no_credentials from the shared _no_creds_result helper, token_invalid from @api_error_handler when the underlying exception is an auth failure (PlatformAuthError, an HTTP 401/403, or a Google Ads authentication_error / authorization_error). The vocabulary lives in mureo/core/auth_failure.py.

An agent must never render an auth_error result as data. A platform that could not be read is not a platform that was quiet, and a report containing one is partial — see the partial-report rule in the /daily-check, /weekly-report and /monthly-report skills. In the two period reports it goes further: a period missing a platform is never compared against a prior period that had it, and the unreadable platform’s KPI is omitted from the persisted rollup rather than written as 0, because that rollup is the next period’s baseline. mureo also treats the envelope as a failed call internally: a mutation that returns it is never written to action_log.

API errors (rate limits, invalid parameters, etc.) are caught by the @api_error_handler decorator and returned as text, prefixed with API error::

[
{
"type": "text",
"text": "API error: Meta API request failed (status=400, path=/act_123/campaigns)"
}
]

The text after the prefix is curated, never the raw str(exception): a platform exception that carries a structured server-side failure (Google Ads) is rendered as that failure’s own message, because formatting such an exception directly prints the transport-level repr — request metadata and credentials included. Every other exception is rendered as its own message (the exception type name when that message is empty).

Missing required parameters raise ValueError, which the MCP protocol surfaces to the client:

ValueError: Required parameter customer_id is not specified
  • Google Ads: Uses gRPC with automatic retry built into the SDK.
  • Meta Ads: mureo monitors the x-business-use-case-usage response header and automatically retries on HTTP 429 with exponential backoff (up to 3 attempts).

AI agents can issue tool calls at high speed, which risks hitting API rate limits and triggering temporary bans. mureo includes a built-in throttling layer (mureo/throttle.py) that transparently rate-limits all outgoing API requests.

PlatformQPSBurstHourly Cap
Google Ads105
Meta Ads201050,000
Search Console55

The throttler uses a token bucket algorithm combined with a rolling hourly cap (Meta Ads only). When the bucket is empty, the request awaits until a token becomes available — no errors are raised and no tool calls are dropped.

Each platform has a module-level singleton throttler that is shared across all MCP tool calls in the same server process. No user configuration is required; throttling is always active.