Skip to content

Architecture

mureo is a local-first control plane for AI-driven ad ops. It does not compete with the official ad-platform MCPs (Meta Ads MCP, Google Ads MCP, etc.) — it consumes them as drivers and provides the layer they structurally cannot: strategic intent, safety gating, and audit trail. Where the official MCPs answer how to call the API, mureo answers should this change happen, what does it mean for the business, and how do we prove it later.

The system is organized into four layers. Each layer has a clear responsibility and communicates downward through well-defined interfaces.

┌─────────────────────────────────────────────────────┐
│ Marketing Goals │
│ (awareness, lead generation, sales, retention) │
├─────────────────────────────────────────────────────┤
│ Strategy Context │
│ STRATEGY.md: Persona, USP, Brand Voice, Goals, │
│ Operation Mode, Market Context │
│ STATE.json: Campaign snapshots, action log │
├─────────────────────────────────────────────────────┤
│ Orchestration Layer │
│ Workflow Commands: /daily-check, /rescue, etc. │
│ Domain Knowledge: Skills (analysis, diagnostics) │
│ AI Agent (LLM): Strategic judgment, creative gen │
├─────────────────────────────────────────────────────┤
│ Tool Connection Layer │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ │
│ │Google Ads│ │Meta Ads │ │Search │ │GA4 │ │
│ │(mureo) │ │(mureo) │ │Console │ │(official│ │
│ │ │ │ │ │(mureo) │ │ MCP) │ │
│ └──────────┘ └──────────┘ └────────┘ └─────────┘ │
│ mureo-native external │
└─────────────────────────────────────────────────────┘

The user defines high-level marketing objectives — awareness, lead generation, sales, retention. These goals drive every decision downstream. mureo does not prescribe goals; it receives them from the marketer and ensures all operations align with them.

mureo persists marketing strategy in two files that travel with the project:

  • STRATEGY.md captures the durable strategic context: target persona, unique selling proposition (USP), brand voice guidelines, marketing goals, the current operation mode (e.g., EFFICIENCY_STABILIZE, GROWTH_SCALE), and market context including competitors.
  • STATE.json holds ephemeral operational state: campaign snapshots, recent action logs, and metric baselines used for anomaly detection.

Together these files give the AI agent enough context to make strategy-aware decisions without requiring a database.

history/ — what STATE.json is allowed to forget (#758 phase 4)

Section titled “history/ — what STATE.json is allowed to forget (#758 phase 4)”

STATE.json is a working document: it is read whole, re-rendered and written back on every mutation, so it has to stay bounded. Two sections pay for that bound with history — platforms[<p>].daily is trimmed to the most recent 35 days on every write, and reports[<kind>] is overwritten by every report write. Since #758 phase 4 neither loss is silent: the write archives what it is about to lose into a history/ directory beside STATE.json, inside the same state-lock critical section, before the trimming write lands.

<workspace>/
STATE.json
history/
daily/2026-08.json # one file per calendar month, every platform in it
reports/weekly.jsonl # append-only, one line per version ever written

One file per month rather than per platform: a platform key can be plugin:<dist>:<provider>, and a key that has to become a filename is a key that can escape its directory — a month is a name mureo mints itself from a date it has already validated. A month file that fails to parse is refused, never overwritten (ContextFileError, naming the file), because rewriting it would delete a month of history to tidy a parse error; readers are tolerant instead and report which file they had to skip. The dashboard and every existing reader still read STATE.json alone — the archives add a record, they do not change what the document holds. Both are written owner-only (0600), but they are written differently: a month file is a read-modify-write, so it goes through the same temp-file → fsync → rename durability as STATE.json itself and holds its own sidecar lock (<month>.json.lock) for the merge — a different lock from STATE.json.lock, since a downstream writer holds the lock of the document it writes. The report ledger is a plain append of one line, like JOURNAL.jsonl: no rewrite, so nothing to make atomic.

A downstream writer that merges daily inside its own atomic document write (mureo.context.daily.with_platform_daily, #710) still drops what it trims unless it passes archive=; the archive is not something the document-level merge can do on its own, since it does not know which file it is writing.

Reading it back (#758 phase 4b). mureo_history_query is the one tool over all of it: action_log and the archived + in-document daily series out of the workspace, JOURNAL.jsonl out of the journal, and history/reports/<kind>.jsonl out of the ledger — one set of filters, limit applied per source, and every section stating whether more matched than it returned. Three bounds are structural rather than cosmetic: the journal is read only to its last 20 000 lines, counted across its rotated files and spent newest first (an unbounded read would grow with the age of the workspace), a daily query with no since reads only the last 12 months of the one-file-per-month archive (same reason, same growth) and reports the window it used, and a query is capped at 200 entries per source, because the answer has to fit in the context of the agent that asked. The tool never writes: the archives are appended by the write path that would otherwise have dropped the data, and nothing reads them to build a screen.

This is where mureo’s workflow commands, domain knowledge (skills), and the AI agent converge. Workflow commands like /daily-check and /rescue define multi-step operational procedures. Skills provide domain-specific reference material (operation mode definitions, diagnostic patterns). The AI agent (LLM) supplies strategic judgment, creative generation, and adaptive decision-making. The orchestration layer reads the strategy context, selects the appropriate tools, and synthesizes results into actionable recommendations.

The workflow commands form a continuous Plan-Do-Check-Act cycle: /onboard defines strategy and goals (Plan); /daily-check and its downstream commands execute operations (Do); /goal-review and /weekly-report evaluate progress (Check); and /goal-review recommendations feed back into the Do phase by adjusting the Operation Mode, which changes how every command behaves (Act). This loop runs daily (Do) and weekly (Check), with the Act phase closing the loop by updating STRATEGY.md when conditions change. See the operational skills under skills/ (daily-check, budget-rebalance, etc.) for the full PDCA cycle.

The bottom layer connects to advertising platforms and analytics services. mureo currently ships its own MCP tools for Google Ads, Meta Ads, and Google Search Console; third-party MCP servers (e.g., GA4) can be composed alongside them. This layer is intentionally replaceable. As platforms release official MCP servers (Meta Ads MCP shipped 2026-04-29; Google Ads MCP available), mureo’s built-in connectors are swapped for official ones with no change to the orchestration layer above. Official MCPs are drivers; mureo is the control plane that drives them.

What mureo provides (that an official MCP cannot)

Section titled “What mureo provides (that an official MCP cannot)”

mureo’s durable value is not platform connectivity — that commoditizes as official MCPs ship. The value is in control-plane responsibilities that platforms structurally will not provide:

Every proposed change passes through a runtime gate that reads STRATEGY.md and decides: allow / deny / require approval. Persona, USP, brand voice, budget rules, allowed mutation scope, and operation mode all become enforcement signals — not just context. An official MCP has no view of your strategy and cannot enforce one.

Mutations are bounded by a rollback allow-list, GAQL injection guards, BYOD read-only-by-default, per-platform throttle (token bucket + rolling hourly cap), and credential-guard hooks. Incorrect agent behavior degrades to “no-op” rather than “live damage.” An official MCP exposes raw API surface; this constraint logic lives in mureo.

Every decision is recorded in an append-only ledger: who proposed, when, with what reasoning, on what evidence, with what predicted impact, with what rollback plan. Decisions are reversible. An official MCP records API calls, not strategic intent. This is the layer that makes AI ad ops survivable in regulated industries (GDPR, CCPA) and through procurement / SOC2 review.

Three trails, three jobs:

TrailWhat is in itWhere
Journal (#758)Every tool call that entered the dispatcher, with its outcome — ok, platform error, exception, policy denial, preflight refusal, invalid arguments — and its masked arguments. The complete primary record; read it with mureo journal.JOURNAL.jsonl in the workspace (else ~/.mureo/journal.jsonl)
action_logThe curated summary: mutations that carry strategy semantics — observation window, reversal plan, batch membership. Much smaller than the journal, and what rollback_plan_get plans from. Since #758 phase 2 each entry also carries the session_id the journal stamps on its own records, so the two trails join: from one entry an operator can pull every call the session that made it also made — plus reason, the rationale the agent gave at the time.STATE.json
decisionsThe reasoning: what was CONSIDERED, what was decided, and on which figures (#758 phase 3). Append-only — an adoption or a rejection is a new record naming the first in supersedes, never an edit — and the only trail that holds a proposal which was turned down, since that changed nothing and so is correctly absent from action_log. Joined to the other two by related_actions and the same session_id.STATE.json
Plugin auditThe pre-#758 trail of plugin / bridge tool calls, kept unchanged for compatibility.~/.mureo/plugin_audit.jsonl

The journal answers “what did this agent actually try”; action_log answers “what changed, and how do I undo it”. A refused or failed attempt is in the first and — correctly — not in the second.

What makes the trail hold up (#758 phase 6). Three properties, one per way it could quietly stop being a record. It is bounded: JOURNAL.jsonl and each history/reports/<kind>.jsonl are rotated away at 32 MiB (MUREO_JOURNAL_MAX_BYTES) under a timestamped name, so a long-lived workspace costs a bounded read — and nothing is ever deleted, the bound is on one file rather than on the history. It is tamper-evident: every journal record carries the hash of the previous physical line and of itself, the chain continues across a rotation, and mureo journal --verify reports the first break with the file and line. The one thing it cannot show is a truncated tail, which is stated in the docs rather than glossed over. And a whole-document STATE.json write that shortens action_log leaves a copy: write_state_file backs the previous document up to STATE.json.bak.<unix_ns> and warns, whoever the writer is — a restore, an agency sync or a repair. The write still proceeds (a document that cannot be written is a workspace nobody can repair), but it can no longer happen unnoticed, and a backup that fails stops the write.

One part of the action_log trail closes itself (#758 phase 5). An entry with an observation_due used to stay open until an agent remembered to append an evaluation_of record for it, and a forgotten one was re-evaluated on every run while the pending set grew. Where the verdict is fully determined by the document — a campaign-level action with a numeric baseline, on a platform whose campaign metrics were collected on or after the due date — mureo_state_get now scores it with the same deterministic evaluate_outcome an agent would call and appends the closure record itself, with the writing session’s id and a machine-built reason. Everything it cannot decide (an external change, a missing baseline, metrics that predate the window) is reported back as a skip with its reason and still owes a manual evaluation. The write faces the same policy gates an action_log append does, so a read-only deployment skips it rather than bypassing them — see mcp-server.md.

Ops history (#848) joins the two trails. A mutating call’s reason and expected become an adopted decision record (or join the batch’s; nothing is joined by similarity), and the action_log entry it produces is stamped with that decision’s id. This happens inside the same locked write as the append, in mureo/context/ops_linkage.py, so an entry and its decision cannot be written apart. A call that writes no entry gets its decision in one locked write right after it succeeds, linked to its JOURNAL.jsonl record by call_id. The automatic closure then scores the change on ONE primary metric — the decision’s expected effect marked primary, else the campaign’s bidding target, else CPA (#888) — behind a per-side volume floor, and stores the full evaluation table on the outcome_evaluated entry. The noise band and the floors are read from STRATEGY.md ## Guardrails at the MCP layer (mureo/mcp/_outcome_thresholds.py) and parsed there, then passed down, so neither the pure evaluator nor mureo/context gains an edge onto mureo/policy. settings.ops_history.enabled makes reason and expected required. The decisions and ops_history sources of mureo_history_query read the join back with a derived state, and nothing about that state is stored. See ops-history.md.

mureo’s value increases — not decreases — as official MCPs ship.

mureo/
├── __init__.py # Package root (version)
├── auth.py # Credential loading & client factory (+ Meta token auto-refresh)
├── auth_setup.py # Interactive setup wizard (OAuth + MCP config + credential guard)
├── logging_setup.py # `mureo configure` log handlers (~/.mureo/logs/configure.log, rotated) + HTTP access-log scrubbing (#581)
├── client_factory.py # Per-platform BYOD-vs-live client factory, shared by every
│ # live-client opener (MCP handlers, analytics, change import);
│ # open_google_ads_client / open_meta_ads_client are the one
│ # Google Ads / Meta Ads openers (#875)
├── throttle.py # Rate limiting (token bucket + rolling hourly cap)
├── installed_packages.py # The installed mureo / mureo-* distributions (PEP 503 names), shared
│ # by `mureo upgrade` and the dashboard's About / update / upgrade (#863)
├── google_ads/ # Google Ads API client
│ ├── client.py # GoogleAdsApiClient (Mixins)
│ ├── mappers.py # Response mapping to structured dicts
│ ├── _ads.py # _AdsMixin (create/update/list RSAs)
│ ├── _ads_display.py # _DisplayAdsMixin (create RDAs + RDAUploadError)
│ ├── _keywords.py # _KeywordsMixin (add/remove/suggest)
│ ├── _extensions.py # _ExtensionsMixin (sitelinks, callouts, conversions, targeting)
│ ├── _monitoring.py # _MonitoringMixin (anomaly detection, reports)
│ ├── _diagnostics.py # _DiagnosticsMixin (campaign diagnosis, one method per delivery check)
│ ├── _analysis.py # _AnalysisMixin (auction insights, CPC trends, device analysis)
│ ├── _creative.py # _CreativeMixin (LP analysis, RSA insights)
│ ├── _media.py # _MediaMixin (image asset upload)
│ ├── _rsa_validator.py # RSA ad text validator
│ ├── _rda_validator.py # RDA input validator (display ads)
│ ├── _gaql_validator.py # GAQL input validators (IDs, dates, date ranges, IN clauses)
│ └── _date_ranges.py # Period-over-period comparison windows (SDK-free, #809)
├── meta_ads/ # Meta Ads API client
│ ├── client.py # MetaAdsApiClient (17 Mixins)
│ ├── mappers.py # Response mapping
│ ├── _campaigns.py # CampaignsMixin
│ ├── _ad_sets.py # AdSetsMixin
│ ├── _ads.py # AdsMixin
│ ├── _creatives.py # CreativesMixin (carousel, collection, image upload)
│ ├── _videos.py # VideosMixin (AdVideo upload, status, thumbnails)
│ ├── _audiences.py # AudiencesMixin
│ ├── _pixels.py # PixelsMixin
│ ├── _insights.py # InsightsMixin
│ ├── _analysis.py # AnalysisMixin
│ ├── _conversions.py # ConversionsMixin (Conversions API / CAPI)
│ ├── _leads.py # LeadsMixin (lead forms, lead data)
│ ├── _catalog.py # CatalogMixin (product catalogs, feeds, products)
│ ├── _split_test.py # SplitTestMixin (A/B tests)
│ ├── _ad_rules.py # AdRulesMixin (automated rules)
│ ├── _page_posts.py # PagePostsMixin (page posts, boost, page photos)
│ ├── _instagram.py # InstagramMixin (accounts, media, boost)
│ ├── _graph_error.py # Graph error detail formatter (never the raw body)
│ └── _hash_utils.py # SHA-256 hashing utilities for CAPI user data
├── amazon_ads/ # Amazon Ads official-MCP bridge (mureo-mediated, #113/#121)
│ ├── bridge.py # AmazonAdsBridge — MCPToolProvider (pure mcp_tools() + forwarding handler)
│ ├── endpoints.py # Regional endpoint URLs (na / eu / fe) + request headers
│ ├── lwa.py # Login with Amazon token exchange (mint / refresh)
│ ├── manifest.py # `mureo amazon refresh-manifest` → ~/.mureo/amazon_tools.json
│ └── provider.py # Registry registration for the in-tree bridge (first-wins on `amazon_ads`)
├── search_console/ # Google Search Console API client (reuses Google OAuth2 credentials)
│ └── client.py # SearchConsoleApiClient
├── analysis/ # Cross-platform analysis utilities
│ ├── lp_analyzer.py # Landing page analysis
│ ├── anomaly_detector.py # CPA spike / CTR drop / zero-spend detection with sample-size gates
│ ├── outcome_note.py # Evaluation note -> parts (#894). Reads the NOTE_* templates of
│ │ # outcome_eval / expected_eval (never the reverse); the served
│ │ # ops history view adds its note_parts, and the browser's
│ │ # outcome_text.js words them. Nothing stored changes
│ └── tracking/ # Tracking-parameter consistency (#550)
│ ├── checks.py # Platform-neutral detector: account audit + pre-upload pre-flight
│ ├── scheme.py # URL -> recognized tracking parameters + comparable value shape
│ ├── convention.py # Opt-in '## Tracking Convention' section of STRATEGY.md
│ └── sources.py # One thin accessor per platform ('this ad's destination URLs')
├── rollback/ # Rollback feature (allow-list gated, append-only)
│ ├── models.py # RollbackStatus / RollbackPlan + batch verdicts (BatchCoverage, BatchRollbackPlan)
│ ├── planner.py # plan_rollback(ActionLogEntry) -> RollbackPlan | None
│ ├── batch.py # plan_batch_rollback(doc, batch_id) -> every member, gaps included (#549)
│ └── executor.py # execute_rollback(...) -> appends ActionLogEntry(rollback_of=index)
├── context/ # File-based context (STRATEGY.md, STATE.json)
│ ├── models.py # Immutable dataclasses (StateDocument, BatchRecord, ...)
│ ├── action_log_entry.py # ActionLogEntry (.rollback_of / .batch_id) + EXTERNAL_ORIGIN (#851), re-exported by models.py
│ ├── display_models.py # The display contract models (#706), re-exported by models.py
│ ├── strategy.py # STRATEGY.md parser / renderer
│ ├── batch.py # Batch id minting, the stamping rule, membership queries (#549)
│ ├── monthly_budget.py # `## Custom: Monthly Budget` reader (#652) — target, not a ceiling
│ ├── monthly_budget_models.py # MonthlyBudget + the SOURCE_* vocabulary (#879), below both
│ │ # monthly_budget.py (which re-exports them) and
│ │ # platform_monthly_budget.py; imports nothing from mureo at runtime
│ ├── platform_monthly_budget.py # What the platforms are CONFIGURED to spend (#656) —
│ │ # the concept is declared by a plugin, the figures are summed
│ │ # on read, an incomplete set is withheld (never shrunk) and
│ │ # says WHICH gap it is, and the two per-platform splits are
│ │ # separate fields the type refuses to cross
│ ├── state.py # The single import site for STATE.json: re-exports the six modules below
│ │ # (state_io and the first five stores), state_codec, conversion_overrides
│ │ # and daily; no logic of its own since #917
│ ├── state_io.py # read_state_file / write_state_file, the atomic write, the state lock, the clock
│ ├── campaign_store.py # upsert_campaign / get_campaign + _platform_base / _stamp_fetched_at
│ ├── action_log_store.py # append_action_log — the single recording choke point
│ ├── batch_store.py # begin_batch / end_batch (the path half of batch.py)
│ ├── platform_store.py # set_platform_metrics / _daily / _not_collected, set_workspace_not_collected,
│ │ # set_conversion_action_types
│ ├── report_store.py # set_report / set_display
│ ├── decision_store.py # append_decision (the path half of decisions.py, #879) — not re-exported
│ ├── settings_store.py # set_ops_history_enabled (the path half of settings.py, #879) — not re-exported
│ ├── state_codec.py # STATE.json <-> StateDocument codec (parse_state / render_state)
│ ├── _platform_codec.py # The platform / campaign / ad entries' two codec halves (#881) —
│ │ # a hand-authored entry is kept and written back in its own shape
│ ├── _preserve.py # The one re-emit rule for an entry kept with its on-disk dict,
│ │ # and the copy of that dict (#859 action_log, #881 platform /
│ │ # campaign / ad, #882 decisions), and the one refusal to record
│ │ # into a `decisions` / `action_log` kept as written (#882)
│ ├── decisions.py # The `decisions` section (#758 phase 3): the record, the bounds,
│ │ # the cross-reference checks and the pure queries. Append-only,
│ │ # because `display.proposals` is replaced whole on every
│ │ # dashboard write and so cannot be a history
│ ├── _decision_codec.py # That section's two codec halves — like action_log's, a
│ │ # refused record is kept opaque in place and unknown keys
│ │ # are written back (#882), because a decision record IS history
│ ├── expected.py # `expected` effects (#848): {metric, direction, target?, horizon_days?, primary?}
│ ├── settings.py # The `settings` section: ops_history.enabled (#848)
│ ├── decision_chain.py # A decision's `supersedes` chain: root, latest, expected,
│ │ # and which change each decision owns (change_owners)
│ ├── ops_intent.py # The call's expected / decision_id, bound by the dispatcher
│ ├── ops_linkage.py # Linking an action_log entry / batch to its decision (#848),
│ │ # inside the append's locked write
│ ├── ops_fields.py # Title and metrics of an automatically created decision
│ ├── ops_history_view.py # Decision → actions → evaluation join with derived states
│ ├── ops_history_duplicates.py # The possible-duplicate hint between a recorded
│ │ # decision and the one a call opened (#851)
│ ├── display_codec.py # The `display` section's two codec halves (#706) — tolerant on
│ │ # read, because every bound on that surface is a WRITE rule
│ ├── conversion_overrides.py # Per-account conversion action_type override lookup (#342)
│ ├── history.py # history/ archives beside STATE.json (#758) — the trimmed
│ │ # daily days and every version of every report kind
│ ├── platform_accounts.py # One ad account, one platform key — the shared account-id join
│ ├── platform_guards.py # Write-time guards over that join (refuse / warn on duplicates)
│ ├── state_store.py # FilesystemStateStore — the default StateStore (STATE.json / STRATEGY.md)
│ └── errors.py # Context-specific exceptions
├── cli/ # Typer CLI (setup + auth + rollback inspection; ad operations are via MCP)
│ ├── main.py # Entry point (mureo command) — registers all 12 sub-command groups
│ ├── setup_cmd.py # mureo setup claude-code / cursor / codex / gemini (Typer handlers)
│ ├── setup_codex.py # Codex install-kit (MCP config, credential guard, workflow-command skills, shared skills)
│ ├── _skill_paths.py # get_data_path / replace_dest, shared by setup_cmd, setup_codex, native_skills, skill_status (#879)
│ ├── setup_gemini.py # Gemini extension manifest (~/.gemini/extensions/mureo/gemini-extension.json)
│ ├── native_skills.py # Deploy/remove plugin `mureo.native_skills` slash skills (#439)
│ ├── auth_cmd.py # mureo auth setup (terminal) / status / check-*
│ ├── configure_cmd.py # mureo configure — launches the local browser configuration UI
│ ├── service_cmd.py # mureo service install / status / restart / uninstall (auto-start daemon)
│ ├── upgrade_cmd.py # mureo upgrade [--all] — upgrades mureo + plugins, re-deploys skills
│ ├── skill_status.py # Deployed workflow skills current / stale / missing (#728), read by
│ │ # upgrade_cmd and web/status_collector.py (#863)
│ ├── byod_cmd.py # mureo byod import / status / remove / clear (Bring Your Own Data)
│ ├── demo_cmd.py # mureo demo — scaffold a self-contained demo workspace
│ ├── providers_cmd.py # mureo providers install / list / remove (official MCP providers)
│ ├── open_cmd.py # mureo open — surface the running configure dashboard
│ ├── learn_cmd.py # mureo learn — append insights to the knowledge base
│ ├── install_desktop_cmd.py # mureo install-desktop — wire mureo into Claude Desktop chat (macOS)
│ ├── rollback_cmd.py # mureo rollback list / show (inspection only; apply routes through MCP)
│ └── _tty.py # TTY-safe helpers (confirm_or_default, is_tty) for non-interactive setup
├── mcp/ # MCP server
│ ├── __main__.py # python -m mureo.mcp entry point
│ ├── server.py # MCP server setup (stdio transport)
│ ├── _plugin_tables.py # _PLUGIN_DISPATCH / _PLUGIN_SEMANTICS dict objects, below server so
│ │ # the journal hook imports them at top level; server fills
│ │ # them in place at load (and on reload) (#879)
│ ├── _server_slots.py # tool_families / dispatcher / policy_evaluator slots, below server so the
│ │ # journal, auto-evaluation and rollback hooks import them at top level;
│ │ # server publishes them at the end of its load (and on reload) (#879)
│ ├── _helpers.py # Shared handler utilities
│ ├── tools_google_ads.py # Google Ads tool definitions (aggregator)
│ ├── _tools_google_ads_*.py # Tool definition sub-modules
│ ├── _handlers_google_ads.py # Google Ads base handlers
│ ├── _google_ads_client.py # Google Ads client / credential resolution
│ ├── _handlers_google_ads_extensions.py # Extensions handlers
│ ├── _handlers_google_ads_analysis.py # Analysis handlers
│ ├── tools_meta_ads.py # Meta Ads tool definitions (aggregator)
│ ├── _tools_meta_ads_*.py # Tool definition sub-modules
│ ├── _handlers_meta_ads.py # Meta Ads base handlers
│ ├── _meta_ads_client.py # Meta Ads client / credential resolution
│ ├── _handlers_meta_ads_extended.py # Extended handlers
│ ├── _handlers_meta_ads_other.py # Other handlers
│ ├── _handlers_meta_ads_creatives.py # Upload / creative handlers
│ ├── tools_search_console.py # Search Console tool definitions
│ ├── _handlers_search_console.py # Search Console handlers
│ ├── tools_rollback.py # rollback_plan_get / rollback_apply
│ ├── _handlers_rollback.py # Rollback handlers (dispatcher read from _server_slots)
│ ├── tools_batch.py # mureo_batch_begin / _end / _status
│ ├── _handlers_batch.py # Batch lifecycle handlers
│ ├── tools_change_import.py # mureo_external_changes_import (#545)
│ ├── _handlers_change_import.py # Change-import handler
│ ├── tools_analysis.py # analysis_anomalies_check / analysis_delivery_collapse_* (#546) / analysis_tracking_consistency_check
│ ├── _handlers_analysis.py # Anomaly detector composition handler
│ ├── _handlers_tracking.py # Tracking-parameter consistency handler (#550)
│ ├── tools_mureo_context.py # STRATEGY.md / STATE.json read-write + outcome eval
│ ├── _handlers_mureo_context.py # Mureo-context handlers (atomic file writes)
│ ├── _campaign_schema.py # mureo_state_upsert_campaign's `campaign` argument schema
│ ├── _outcome_thresholds.py # Outcome verdict thresholds from ## Guardrails (#888)
│ ├── tools_analytics_registry.py # mureo_analytics_modules_list / _run (#440)
│ ├── tools_learning.py # mureo_learning_insights_get / mureo_consult_advisor
│ └── tools_creative_studio.py # creative_studio_* (generate / edit / compose)
├── core/ # Provider registry + shared entry-point plumbing (mureo.native_skills group)
│ # and the RuntimeContext store Protocols (StateStore, ...). core never
│ # imports context (#863): the filesystem StateStore default lives in
│ # mureo.context.state_store, and runtime_context reaches it by object
│ # reference (DEFAULT_STATE_STORE_REF); mureo.core still exports the name.
│ # tool_names.py owns MUTATION_PREFIXES, which the BYOD clients
│ # import; core imports nothing from byod (#879).
│ # platform_keys.py defines ANALYTICS_ENTRY_POINT_GROUP, which
│ # context.platform_guards reads without importing analytics and
│ # analytics.registry re-exports (#879).
│ # tool_failure.py is the MCP failure-envelope reader (failure_of);
│ # the writer stays in mcp/_helpers.py, which re-exports it (#863).
│ # tool_failure.exception_text is the one exception-to-text formatter,
│ # used by mcp and the platform packages alike (#603, #865).
│ # rollback, policy, amazon_ads and core never import mureo.mcp (#863):
│ # the server publishes what they need at load — plugin reversal keys
│ # (rollback.planner.set_plugin_reversal_keys) and built-in tool names
│ # (policy.learning_reset.set_builtin_platform_tool_names) — as it does
│ # for the policy declarations.
│ # credential_source.py (the per-call credential-source ABI) and
│ # account_scope.py (#411 workspace account scoping) sit here, and the
│ # BYOD-or-live client factory is mureo/client_factory.py: all three are
│ # below mcp, so analytics and change import open live clients without
│ # ever importing mureo.mcp (#863).
│ # meta_ads never imports auth or auth_setup (#863): the Meta OAuth
│ # scopes and Graph timeout are public in mureo.meta_ads.accounts
│ # (META_OAUTH_SCOPES / META_HTTP_TIMEOUT_SECONDS) and auth_setup
│ # imports them. The configure OAuth wizard lives in
│ # mureo.web.auth_wizard. The legacy credentials location has one
│ # definition, core.secret_store.default_credentials_path();
│ # runtime_credentials_path() falls back to it when given no default.
│ # ga4_adc.py derives the ga4-official ADC file from a Google
│ # authorization granted analytics.readonly (#938), as
│ # <store name>-ga4-adc.json beside the store, never over a ga4
│ # section naming another file; it takes plain strings, so core
│ # still never imports auth. auth_setup, the CLI and
│ # web.env_var_writer (removal, dashboard edits re-write or remove
│ # the file) / status_collector import its names.
│ # google_scopes.py holds the Google OAuth scopes and the
│ # granted-set check both auth_setup OAuth paths run.
│ # Function-local imports: ruff PLC0415, reasons per AGENTS.md (#863).
├── policy/ # Deterministic policy gate (STRATEGY.md → allow / deny / require-approval)
│ # guardrails.py parses `## Guardrails` (Guardrails, parse_guardrails,
│ # guardrails_from_entries) and imports nothing from context, so
│ # context.monthly_budget reads it at top level; strategy_gate
│ # re-exports its public names (#879).
├── providers/ # Official-MCP provider catalog (Google Ads / Meta / GA4 install kits)
│ # credential_env.py resolves the env a provider reads from the
│ # credential store and gates the native disable on it; both
│ # `mureo providers add` and the configure UI install use it (#945).
│ # It also holds the env-var allow-list; providers imports only core,
│ # and web/env_var_writer.py imports the table back (web -> providers).
├── learning/ # /learn knowledge base + advisor federation client
├── creative_studio/ # Creative Studio: image providers, art-direction scoring, HTML/CSS composer
│ # providers/_base.py holds the ImageProvider protocol and the helpers the
│ # built-in providers share; providers/__init__.py (the registry) imports
│ # the built-ins at top level and re-exports the public names (#879).
├── analytics/ # Analytics-module protocol + registry (mureo.analytics entry-point group)
│ # builtin/ builds the native modules (builtin_analytics_modules()); the
│ # registry imports it and registers them, so builtin never imports the
│ # registry (#879).
├── change_import/ # Import changes made outside mureo into action_log (#545)
│ ├── models.py # ExternalChange / ChangeFeedResult / ChangeImportOutcome
│ ├── protocol.py # ChangeFeedProvider (mureo.change_feeds entry-point group)
│ ├── registry.py # Discovery + fault isolation + lookup; registers builtin_change_feeds()
│ │ # on first use (builtin/ never imports the registry, #879)
│ ├── dedupe.py # Already-imported vs mureo's own change
│ ├── importer.py # Window, action_log write, per-platform outcome
│ └── builtin/google_ads.py # change_event feed (the only native feed today)
├── byod/ # Bring Your Own Data (XLSX bundle importer + CSV-backed read-only clients)
├── adapters/ # Protocol reference implementation (mureo.core.providers); BYOD adapters: byod/adapters/
├── demo/ # Synthetic demo workspace scaffolding
└── web/ # `mureo configure` local dashboard (stdlib http.server + static assets)
│ # Layer rule with cli (#863): web imports cli helper modules
│ # (setup_cmd, setup_codex, native_skills, settings_remove, skill_status,
│ # ...) by public names; cli imports only web leaf modules (instance,
│ # service, report_clients) plus the entry command configure_cmd.
│ # Importing a web submodule never loads web/server.py unless it needs
│ # it: mureo.web resolves ConfigureWizard / run_configure_wizard on
│ # access, and DEFAULT_CONFIGURE_PORT lives in instance.py.
│ # Failures are 4xx / 5xx {"error": <code>, "detail"?} written by
│ # _helpers.send_error_json (see "Failure contract" in plugin-authoring.md).
├── action_result.py # ActionResult (refused = 400, failed = 500) + send_result (#864);
│ # send_steps for the multi-step routes: 200 per-step envelope, or
│ # 500 basic_setup_failed / basic_clear_failed + "steps" when every
│ # step failed (#891)
├── plugin_oauth_revoke.py # Plugin Disconnect + best-effort RFC 7009 revocation of a stored
│ # token at AccountOAuthConfig.revoke_url, on re-auth and Disconnect (#970)
├── plugin_oauth_spec.py # PluginOAuthSpec (the frozen plugin OAuth hand-off from oauth_bridge.py
│ # to auth_wizard.py) + persist_plugin_token, the callback's save step
├── static_assets.py # The files GET /static/<name> may serve (_STATIC_ALLOWLIST) and
│ # their content types (_STATIC_CONTENT_TYPES)
└── auth_wizard.py # OAuth handoff server spawned by web/oauth_bridge.py (Google + Meta)
skills/ # Native slash skills — deployed to ~/.claude/skills/ and ~/.codex/skills/
├── onboard/ # Account setup + STRATEGY.md generation
├── daily-check/ # Mode-aware daily health monitoring
├── rescue/ # Emergency performance rescue
├── search-term-cleanup/ # Strategy-aligned search term hygiene
├── creative-refresh/ # Persona/USP-driven ad copy refresh
├── creative-generate/ # Strategy-grounded creative generation (Creative Studio)
├── lead-form-create/ # Interview-then-create Meta Instant Form (Lead Ad form)
├── budget-rebalance/ # Mode-guided budget reallocation
├── competitive-scan/ # Auction analysis with Market Context
├── sync-state/ # Manual STATE.json synchronization
├── learn/ # Save diagnostic insights to knowledge base
├── ... (28 skill dirs total) # weekly-report, goal-review, monthly-report, tracking-health, …
├── _mureo-strategy/ # Shared: STRATEGY.md / STATE.json conventions
├── _mureo-state-schema/ # Shared: STATE.json schema + status vocabulary contract (Code `Write`)
├── _mureo-report-persist/ # Shared: daily-check persistence steps (report, rollups, history, display)
├── _mureo-learning/ # Shared: evidence-based decision framework
└── _mureo-pro-diagnosis/ # Shared: learnable diagnostic knowledge base (grows with /learn)

mureo has zero database dependencies. All state lives either in the advertising platform APIs or in optional local files (STRATEGY.md, STATE.json). This makes it trivially deployable — pip install mureo is all you need.

mureo does not bundle or call an LLM itself. The tool connection layer wraps advertising APIs and returns structured JSON dictionaries. The orchestration layer relies on an external AI agent (e.g., Claude via Claude Code) for reasoning, planning, and creative generation. This separation keeps mureo lightweight and model-agnostic.

All dataclasses use frozen=True to prevent accidental mutation. Mutable fields like dict and list are defensively copied in __post_init__.

@dataclass(frozen=True)
class CampaignSnapshot:
campaign_id: str
campaign_name: str
status: str
bidding_strategy_type: str | None = None
# ...

Every tool and CLI command returns plain Python dicts (serializable to JSON). No custom objects, no ORM models — just data that any agent or script can consume.

Credentials are loaded from ~/.mureo/credentials.json or environment variables. They are never sent anywhere except the official advertising platform APIs.

When ~/.mureo/byod/manifest.json registers a platform, the client factory (mureo/client_factory.py, used by the MCP handlers, analytics and change import) dispatches the BYOD CSV-backed client (mureo/byod/clients.py) instead of the live API client — per platform, decided at every call. Real credentials are never read for that platform; mutations are blocked at the client level (every method whose name starts with create_, update_, delete_, pause_, resume_, etc. returns {"status": "skipped_in_byod_readonly"}). The activation signal is purely the manifest’s existence — there is no --byod flag, and mureo setup claude-code does not need to be re-run when a user starts or stops importing data.

The user-facing input is a single XLSX bundle: either the mureo Google Ads Script’s Sheet output (scripts/sheet-template/google-ads-script.js) or a Meta Ads Manager Saved Report export. The bundle importer (mureo/byod/bundle.py) opens the workbook with openpyxl, dispatches each adapter (mureo/byod/adapters/<platform>.py) by header signature, and writes per-platform CSVs to ~/.mureo/byod/<platform>/. Manifest update is atomic with rollback on partial failure. GA4 and Search Console are not part of the BYOD bundle pipeline — they remain on the Live API OAuth path. See docs/byod.md for the user-facing walkthrough.

Claude Code ─MCP──▶ mureo MCP server
│
▼ client_factory.py per-platform routing
byod_has(p)? yes──▶ Byod*Client (CSV) ──▶ ~/.mureo/byod/<platform>/*.csv
no ──▶ create_*_client (Live API) ──▶ Google Ads / Meta / SC / GA4 API

Google Ads has one opener for all three callers, open_google_ads_client in mureo/client_factory.py (#875). It returns (client, resolved_customer_id) and owns the precedence (an active credential source, then BYOD, then the credentials file), the workspace scoping (resolve_customer_id, on every path; BYOD with no customer_id, or with "byod" itself, opens as "byod" unchecked), the numeric id check, and the shared GOOGLE_ADS_THROTTLER. It raises NoCredentialsError when no credentials are configured and AccountIdRefused (a ValueError subclass) on a scope refusal or a non-numeric id; any other exception from credential loading or client construction is a fault and propagates unchanged. Each caller maps the two to its own result: the MCP tools return the no-credentials result, analytics turns AccountIdRefused (and only that) into AccountNotAvailableError, and change import lets both surface as an ERROR outcome (it returns no client in BYOD mode before calling the opener, since BYOD has no change history).

Meta Ads has the same shape for its two callers (the MCP tools and analytics), open_meta_ads_client in mureo/client_factory.py (#875). It is a coroutine because it refreshes a file-sourced token near its expiry (refresh_meta_token_if_needed, #726); a credential source’s token is used as-is (the source owns its lifecycle, #821) and BYOD has none. It returns (client, resolved_account_id), binds the id to the workspace allow-list (resolve_account_id) and canonicalizes it to the act_ form (a bare id is prefixed); BYOD with no account_id, or with "act_byod" itself, opens as "act_byod" unchecked. Every live client gets the shared META_ADS_THROTTLER. The failures and their mapping are the Google Ads ones: NoCredentialsError, AccountIdRefused (a scope refusal), and analytics turns only AccountIdRefused into AccountNotAvailableError.

mureo assumes the caller is an AI agent susceptible to prompt injection, not a trusted human. Five layered controls address that threat model:

  1. Credential guard — mureo setup claude-code writes a PreToolUse hook to ~/.claude/settings.json that blocks reads of ~/.mureo/credentials.json, .env, and similar secret files, so a prompt-injection payload cannot exfiltrate tokens via the file-system tools.

  2. GAQL input validation — every ID, date, date-range constant, and string literal entering a Google Ads query flows through a single whitelist-based surface in mureo/google_ads/_gaql_validator.py. _period_to_date_clause’s explicit-range branch (YYYY-MM-DD..YYYY-MM-DD or GAQL BETWEEN spelling) pattern-matches and revalidates its dates and re-emits the BETWEEN clause instead of passing the raw caller string into GAQL.

  3. Anomaly detection — mureo/analysis/anomaly_detector.py compares current campaign metrics against a median-based baseline built from historical action_log entries and emits prioritized alerts for zero spend (CRITICAL), CPA spikes (≥1.5×, critical at 2×), and CTR drops (≤0.5×, critical at 0.3×). Sample-size gates (30+ conversions, 1000+ impressions) follow the _mureo-learning skill’s statistical-thinking rules to suppress single-day noise. Baselines tolerate malformed metrics_at_action rows; CPA/CTR are medianed per-entry so baseline values reflect a real historical day. Counts are medianed per day: an entry that states its window (days, or a period such as LAST_30_DAYS) has them divided by it, one that states none counts as one day, and cost / spend are one figure.

  4. Tracking-parameter consistency — mureo/analysis/tracking/ detects ads whose final-URL tracking parameters disagree with the campaign they live in: a silent defect, because delivery and spend look healthy while the analytics everyone downstream trusts is quietly wrong. The detector is platform-neutral (it sees only AdTrackingRecord) with one thin accessor per platform, and it derives its verdict from evidence already in the account — never from a guessed naming convention. Operator intent is declared in STRATEGY.md’s ## Tracking Convention and parsed by mureo, not interpreted by the agent. Exposed as analysis_tracking_consistency_check, used by /tracking-health for the account audit and as a pre-flight before ads are created. See tracking-consistency.md for the exhaustive list of what it cannot detect.

  5. Rollback with allow-list gating — mureo/rollback/ turns agent-authored reversible_params hints into concrete RollbackPlan records. reversible_params is untrusted input for the rollback executor, so the planner enforces an explicit allow-list of operations (budget update + status toggles across Google/Meta Ads), refuses destructive verbs (.delete / .remove / .destroy / .purge / .transfer), and rejects unexpected parameter keys — a compromised agent cannot smuggle a privileged call through the rollback path. The mureo rollback list / show CLI commands are inspection-only; execution stays with the MCP dispatcher so it re-enters the same policy gate as forward actions, and control characters from STATE.json are stripped before terminal output to prevent ANSI-escape spoofing.

    A bulk change is planned as one unit (#549): mureo_batch_begin / mureo_batch_end declare the boundary, every action_log entry written in between is stamped with the batch id at the single append_action_log choke point (so native, hosted-connector and bridged/plugin recordings all join without any per-platform code), and rollback_plan_get takes that id and classifies every member. Coverage is reported overall and per platform as full / partial / none — because reversibility is not uniform across platforms, and a plan that quietly omitted the members mureo cannot reverse would read as a complete revert. The same allow-list decides each member, so nothing about the guarantee is loosened by grouping.

    Change import (#545) is the read side of the same guarantee: mureo/change_import/ polls each platform’s change feed and records what mureo did not do, marked origin: "external" so it can never be confused with a change mureo dispatched. The rollback planner refuses every external entry before any other check — mureo never captured the prior value, so a “reversal” built from a hint on such an entry would be a fresh change dressed as a restoration, and a batch mixing the two reports partial coverage rather than promising a full revert. Platforms with no feed are reported change_import_unavailable_for_<platform>, never as “no changes” — see change-import.md.

See SECURITY.md for the full threat model.

Both API clients use multiple inheritance with Mixins to organize functionality by domain. This keeps each file focused on a single concern while providing a unified client interface.

class GoogleAdsApiClient(
_AdsMixin, # RSA CRUD (create/update/list/status)
_DisplayAdsMixin, # RDA creation with auto image upload
_KeywordsMixin, # Keyword add/remove/suggest/diagnose
_MonitoringMixin, # Anomaly detection, reports, goals
_ExtensionsMixin, # Sitelinks, callouts, conversions, targeting
_DiagnosticsMixin, # Campaign delivery diagnosis
_AnalysisMixin, # Auction insights, CPC trends, device analysis, budget efficiency
_CreativeMixin, # LP analysis, RSA insights
_MediaMixin, # Image asset upload
):

The base class (GoogleAdsApiClient) provides:

  • Constructor: accepts Credentials, customer_id, developer_token (optional), login_customer_id
  • _search(query): async GAQL query execution via run_in_executor
  • _get_service(name): access to Google Ads service objects
  • Input validation: _validate_id(), _validate_status(), _validate_match_type(), _validate_date()
  • Error handling: _wrap_mutate_error() decorator that catches GoogleAdsException and returns user-friendly messages
  • Partial reads: in a composite result (diagnostics, performance analysis, monitoring, budget reallocation) a read that fails sets its key to None and logs Failed to retrieve <what> (<ExceptionClass>) at WARNING; derived insights skip a None input, and a google_ads_monitoring_* verdict that rests on a failed read is status: "unknown" unless data that was read proves critical (#865)
class MetaAdsApiClient(
CampaignsMixin, # Campaign CRUD
AdSetsMixin, # Ad set CRUD
AdsMixin, # Ad CRUD
CreativesMixin, # Creative management, image/carousel/collection
VideosMixin, # AdVideo upload (URL/file), status polling, thumbnails
AudiencesMixin, # Custom/lookalike audiences
PixelsMixin, # Pixel stats and events
InsightsMixin, # Performance reports, breakdowns
AnalysisMixin, # Performance analysis, cost investigation
CatalogMixin, # Product catalogs, feeds, products (DPA)
ConversionsMixin, # Conversions API (CAPI) event sending
LeadsMixin, # Lead forms, lead data retrieval
PagePostsMixin, # Page post listing and boosting
InstagramMixin, # Instagram accounts, media, boosting
SplitTestMixin, # A/B test creation and management
AdRulesMixin, # Automated rules (alerts, auto-pause, etc.)
TargetingMixin, # Interest / category targeting-ID discovery (read-only)
):

The base class provides:

  • Constructor: accepts access_token, ad_account_id (must start with act_)
  • HTTP methods: _get(), _post(), _delete() with rate limit monitoring
  • Automatic retry with exponential backoff (3 attempts)
  • Rate limit header parsing (x-business-use-case-usage)
  • Async context manager support (async with)

The MCP server uses stdio transport and dispatches tool calls to platform-specific handlers.

Agent (Claude Code / Cursor / etc.)
│
│ stdio (JSON-RPC)
▼
server.py :: _create_server()
│
├── list_tools() → returns _ALL_TOOLS (GOOGLE_ADS_TOOLS + META_ADS_TOOLS + SEARCH_CONSOLE_TOOLS
│ + … + plugin / bridge tools appended last)
│
└── call_tool(name, arguments)
│
│ (every exit below — result, refusal, denial, exception — leaves
│ exactly one journal.record_call(...) line; see "Audit" above)
│
│ ├── name in _GOOGLE_ADS_NAMES? → handle_google_ads_tool(name, args)
│ │
│ └── _HANDLERS[name](args)
│ │
│ │ ├── open_google_ads_client(customer_id) (asyncio.to_thread;
│ │ │ credentials, scoping, GOOGLE_ADS_THROTTLER — client_factory)
│ └── client.method() → list[TextContent]
│
│ ├── name in _META_ADS_NAMES? → handle_meta_ads_tool(name, args)
│ │
│ └── _HANDLERS[name](args)
│ │
│ │ ├── await open_meta_ads_client(account_id) (credentials,
│ │ │ scoping, token refresh, META_ADS_THROTTLER — client_factory)
│ └── client.method() → list[TextContent]
│
│ ├── name in _SEARCH_CONSOLE_NAMES? → handle_search_console_tool(name, args)
│ │
│ └── _HANDLERS[name](args)
│ │
│ │ ├── load_google_ads_credentials() (reuses Google OAuth2)
│ │ ├── create_search_console_client(creds)
│ └── client.method() → list[TextContent]
│
│ ├── name in _PLUGIN_NAMES? → provider.handle_mcp_tool(name, args)
│ │ (entry-point provider plugins AND the in-tree Amazon Ads bridge)
│ │
│ │ ├── plugin throttle (shared bucket, per-tool for read-only)
│ │ ├── forward to the provider / the official Amazon MCP endpoint
│ │ ├── record_plugin_call(...) → append-only jsonl audit (secrets scrubbed)
│ └── successful mutation → action_log (platform="plugin:<dist>:<provider>")
│
└── else → ValueError("Unknown tool")

Key implementation details:

  1. Tool definitions are mcp.types.Tool objects with inputSchema (JSON Schema).

  2. Handler dispatch uses a dict[str, Callable] mapping tool names to async handler functions.

  3. Error handling: the @api_error_handler decorator catches exceptions and converts them to TextContent error messages, so the agent always gets a text response.

  4. Credential loading happens per-request. Each handler call loads credentials from file/env, creates a fresh client, and executes the operation.

  5. Plugin / bridge branch. Tool families that are not built in are collected once at server start via collect_plugin_tools() and appended to _ALL_TOOLS; a name collision with a built-in is dropped. Both third-party mureo.providers entry-point plugins and the in-tree Amazon Ads bridge (mureo/amazon_ads/, registered by register_amazon_provider() before entry-point discovery so the in-tree bridge wins the amazon_ads name) ride this one path, so they get the same audit / throttle / strategy-gate / action_log treatment. The bridge’s mcp_tools() is pure — it reads ~/.mureo/amazon_tools.json only, with no credentials and no network — so a missing or broken manifest means “no Amazon tools”, never a startup failure.

  6. Startup cost is a contract, not an implementation detail (#807). An MCP client gives the server a fixed window to answer initialize — 30,000 ms by default in Claude Code, and not negotiable from inside the server — so importing mureo.mcp.server must stay cheap. Measured on one development machine on 2026-10-08 as the import’s own CPU (time.process_time inside a child interpreter, min of 5 runs, bytecode cache warm; the whole import, jsonschema’s own included), with no plugins installed that import cost 3.1 s and left 2249 modules in sys.modules; it now costs 1.1 s and leaves 882. With a mureo.runtime_context_factory plugin installed — the configuration #807 was reported from — 3.1 s / 2259 before and 1.2 s / 892 after. With a provider plugin that imports a platform client at module scope, 3.2 s / 2250 before and 2.4 s / 2229 after: that remainder is the plugin’s own import, which mureo cannot defer (see docs/plugin-authoring.md). Two rules keep the server’s own share where it is, both enforced by tests/test_mcp_startup_budget.py:

    • No platform SDK or API client may be asked for on mureo’s import path. The mureo.google_ads / mureo.meta_ads packages resolve their public names and submodules lazily via PEP 562 __getattr__ (mureo/_lazy_package.py), so importing a stdlib-only helper from one of them does not pull the client and its generated protobuf tree; mureo/adapters/*/adapter.py imports its client under TYPE_CHECKING plus one function-local import for the constructor’s isinstance check. That second one is not cosmetic: collect_plugin_tools() builds every provider during the server’s import, the Amazon bridge’s manifest location comes from the RuntimeContext, and an integrator’s registered factory imports the adapter packages — so one registered factory used to put the whole SDK stack back (2259 modules, 1150 of them forbidden). The test installs a synthetic factory rather than waiting for one, because CI installs no plugins. It judges by attribution, not by comparison: a sys.meta_path finder records which module first asked for each forbidden module, and none of them may be mureo’s. A plugin that imports a platform client of its own is charged for it and the server is not — the set difference this used to be was empty for most of the forbidden list in exactly that installation, which is to say it stopped checking where it was needed most.
    • No per-tool JSON Schema metaschema check at import. mureo.mcp._tool_validation.LazyToolValidators compiles a tool’s inputSchema validator on that tool’s first call, which is the call it is enforced on. check_schema costs a median of 3.1 ms per tool and 0.82 s summed over the 228-tool catalog (same machine, measured 2026-10-08 by calling it directly on each schema, time.process_time, min of 5 — a separate measurement from the import figures above), and it is the one startup term that grows with the catalog while the client’s budget does not. What a schema that cannot be used costs depends on which layer meets the call first. Through the MCP framework, mcp (1.30.0 checked) validates the arguments before mureo’s dispatcher runs, with a jsonschema.validate() that runs the metaschema check itself, and turns any exception from it into an error result: for a schema that declares no $schema or declares Draft 2020-12 (mcp picks the draft from $schema; mureo’s layer always uses 2020-12), all three kinds of fault below are refused there, with the third-party message, which does not name the tool, and mureo’s layer never sees the call — no ToolSchemaUnusableError, no log record, no PluginToolWarning. mureo’s layer is the only check on an mcp without validate_input (pyproject.toml allows mcp>=1.8.0,<2), on a direct handle_call_tool call, and on the rollback handlers’ reversals, which _handlers_rollback._get_dispatcher() sends through that direct call, real-spend ones included. There a schema that is not valid JSON Schema costs its tool its input validation, as before; one that cannot be compiled for any other reason, or that raises when applied to a call, gets that call refused before any handler runs (ToolSchemaUnusableError), so no call is served with bounds that could not be checked. Schemas mureo does not own — a plugin’s — are still checked at startup, where the operator can see the report; mureo’s own are checked in CI by tests/test_mcp_strict_input_schemas.py, against both the authored schema and the one the server actually compiles (they differ: _reason_param rebuilds every mutating tool).

    Building the server object itself is free — the tool schemas are already assembled by then.

    What the start no longer pays, the first call that needs it does. The Google Ads SDK is imported by the first Google Ads tool call, inside create_google_ads_client, which is synchronous. Every async path that builds a Google Ads client runs it in a worker thread through asyncio.to_thread (#809): the handlers’ shared _get_client, and through it the exclusion-impact and before-state reads, plus change import and the built-in analytics modules. The import is paid there, and the event loop keeps serving other calls while it happens. list_accessible_accounts, which google_ads_accounts_list uses when no customer_id is given, runs its SDK import and gRPC calls in a worker thread the same way. The analytics modules’ period-over-period windows come from mureo/google_ads/_date_ranges.py, which imports nothing from the SDK, so in BYOD mode, where the client is CSV-backed, they never load it. The server’s own import stays SDK-free (#807). Plugin authors are held to the same bar: blocking work in handle_mcp_tool goes through asyncio.to_thread (see docs/plugin-authoring.md). On the same development machine (2026-10-09, time.process_time in a child interpreter right after import mureo.mcp.server, bytecode cache warm, 5 runs after a discarded warm-up) that import costs about 1.2 s of CPU (1.22-1.28 s, against 0.93-0.99 s for the server’s whole import in the same runs) and takes sys.modules from 882 to 2228. The Meta client costs under 10 ms after it.

mureo includes a built-in rate limiter (mureo/throttle.py) to prevent API bans caused by high-speed requests from AI agents.

Each platform throttler combines two mechanisms:

  1. Token bucket — controls instantaneous QPS (queries per second) with configurable burst allowance.
  2. Rolling hourly cap — enforces a hard ceiling on total requests per hour.
PlatformQPSBurstHourly LimitNotes
Google Ads105(none)Conservative defaults; Google uses dynamic server-side limits
Meta Ads201050,000Tuned to stay within the Business Use Case (BUC) quota
Search Console55(none)Reuses Google OAuth2 credentials
Plugin / bridge tools55(none)One shared conservative bucket for provider plugins and the Amazon Ads bridge; a tool that declares its own throttle hint gets a private bucket
  • Module-level singletons — one Throttler instance per platform, shared across all MCP tool calls in the same process. Google Ads’ is GOOGLE_ADS_THROTTLER and Meta Ads’ is META_ADS_THROTTLER, both in mureo/client_factory.py, so the analytics modules (and, for Google Ads, change import) share them too (#875).
  • Transparent — tool handlers call await throttler.acquire() before making API requests. No configuration is needed from the user.
  • Graceful — when the token bucket is empty, acquire() awaits until a token becomes available rather than raising an error.
1. Handler receives tool call arguments
│
2. load_google_ads_credentials() / load_meta_ads_credentials()
/ load_amazon_ads_credentials()
│
│ ├── Try ~/.mureo/credentials.json
│ └── Parse JSON → extract platform section
│
└── Fallback to environment variables
└── GOOGLE_ADS_* / META_ADS_* / AMAZON_ADS_*
│
3. If credentials found:
│
│ ├── Google Ads: build OAuth2 Credentials → GoogleAdsClient → GoogleAdsApiClient
│ ├── Meta Ads: MetaAdsApiClient(access_token, ad_account_id)
└── Amazon Ads: mint/refresh the LwA access token → Bearer header → forward
to the regional official-MCP endpoint
│
4. If no credentials: return error TextContent (no exception)

The credential resolution logic is centralized in mureo/auth.py. Both the CLI and MCP server use the same load_*_credentials() and create_*_client() functions.

When loading Meta Ads credentials, mureo/auth.py first checks token_never_expires — Meta’s own debug_token verdict for a token issued without an expiry — and a token carrying it is never exchanged, whatever else is stored. Otherwise it uses token_expires_at when the paste card learned one (due 7 days before that date), and falls back to the token_obtained_at timestamp in credentials.json: if the Long-Lived Token is 53+ days old (7-day safety margin before the 60-day expiry), mureo automatically exchanges it for a fresh token via the Meta Graph API. This requires app_id and app_secret to be present in the credentials. The refresh is protected by an asyncio.Lock to prevent concurrent refresh races, and the updated token is written atomically to credentials.json with 0600 file permissions. If the refresh fails (network error, invalid app credentials, etc.), mureo falls back to the existing token and logs a warning.

The Amazon bridge is the reason mureo sits in the request path rather than letting the host talk to Amazon’s official MCP directly. When both refresh_token and client_secret are present in the amazon_ads section, mureo/amazon_ads/lwa.py performs a Login-with-Amazon exchange (grant_type=refresh_token against the regional token host) before the first forwarded call when no access token is stored, and exactly one refresh-and-retry on the first failure of a forwarded call — one exchange per dispatch, never a loop. The new token is persisted through save_amazon_access_token(), which takes the same cross-process credentials.json.lock and atomic 0600 write path as every other credentials writer, and refuses to overwrite a malformed file rather than dropping other providers’ sections. See amazon-ads.md.

In addition to the 229 individual MCP tools, mureo provides workflow commands as Claude Code native slash skills (deployed to ~/.claude/skills/). These commands are platform-agnostic orchestration instructions that guide the AI agent to discover platforms, select tools, and synthesize cross-platform insights — all driven by the strategy context in STRATEGY.md.

User runs /daily-check in Claude Code
│
├── Read STRATEGY.md → extract Operation Mode, Persona, Data Sources
├── Read STATE.json → discover configured platforms (platforms dict)
│
├── For each configured platform, select appropriate MCP tools based on Operation Mode
│ (no hardcoded platform assumptions — adapts to whatever is configured)
│
├── Check availability of enrichment data sources:
│ │ ├── Search Console (built-in) → organic search pulse
│ └── GA4 (external MCP) → on-site behavior correlation
│
├── Execute selected tools across all discovered platforms
│
└── Synthesize results into a unified, strategy-aware cross-platform report

All commands follow the same orchestration pattern: discover platforms → select tools → correlate data sources → present unified results. Commands do not hardcode tool names; the AI agent chooses appropriate tools per platform at runtime.

CommandPurposeStrategy Sections Used
/onboardPlatform discovery + strategy generation(generates all sections)
/daily-checkCross-platform health monitoringOperation Mode, Data Sources
/rescueEmergency performance fix with site-side diagnosisAll sections
/search-term-cleanupKeyword hygiene with paid/organic overlapPersona, USP, Data Sources
/creative-refreshMulti-platform creative refreshPersona, USP, Brand Voice, Data Sources
/budget-rebalanceCross-platform budget optimizationOperation Mode, Goals, Data Sources
/competitive-scanPaid + organic competitive analysisMarket Context, Data Sources
/goal-reviewMulti-source goal evaluationOperation Mode, Goals, Data Sources
/weekly-reportCross-platform weekly reportAll sections
/sync-stateMulti-platform state synchronization(writes STATE.json)
/learnSave diagnostic insights to knowledge base(writes SKILL.md)

The operational skills (skills/daily-check/, skills/budget-rebalance/, …) documents the full set of Operation Modes and their behavioral implications for each command, as well as cross-platform data correlation patterns.