Architecture
Architecture
Section titled “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.
System Architecture
Section titled “System Architecture”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 │└─────────────────────────────────────────────────────┘Marketing Goals (Top Layer)
Section titled “Marketing Goals (Top Layer)”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.
Strategy Context
Section titled “Strategy Context”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 writtenOne 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.
Orchestration Layer
Section titled “Orchestration Layer”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.
PDCA Operational Loop
Section titled “PDCA Operational Loop”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.
Tool Connection Layer
Section titled “Tool Connection Layer”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:
Strategy
Section titled “Strategy”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.
Safety
Section titled “Safety”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:
| Trail | What is in it | Where |
|---|---|---|
| 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_log | The 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 |
decisions | The 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 audit | The 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.
Package Structure
Section titled “Package Structure”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)Design Principles
Section titled “Design Principles”No Database
Section titled “No Database”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.
No Embedded LLM
Section titled “No Embedded LLM”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.
Immutable Data Models
Section titled “Immutable Data Models”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 # ...Structured JSON Output
Section titled “Structured JSON Output”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 Stay Local
Section titled “Credentials Stay Local”Credentials are loaded from ~/.mureo/credentials.json or environment variables. They are never sent anywhere except the official advertising platform APIs.
BYOD Mode (Bring Your Own Data)
Section titled “BYOD Mode (Bring Your Own Data)”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 APIGoogle 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.
Defense-in-Depth for AI Agents
Section titled “Defense-in-Depth for AI Agents”mureo assumes the caller is an AI agent susceptible to prompt injection, not a trusted human. Five layered controls address that threat model:
-
Credential guard —
mureo setup claude-codewrites a PreToolUse hook to~/.claude/settings.jsonthat blocks reads of~/.mureo/credentials.json,.env, and similar secret files, so a prompt-injection payload cannot exfiltrate tokens via the file-system tools. -
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-DDor GAQLBETWEENspelling) pattern-matches and revalidates its dates and re-emits theBETWEENclause instead of passing the raw caller string into GAQL. -
Anomaly detection —
mureo/analysis/anomaly_detector.pycompares current campaign metrics against a median-based baseline built from historicalaction_logentries 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-learningskill’s statistical-thinking rules to suppress single-day noise. Baselines tolerate malformedmetrics_at_actionrows; 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 aperiodsuch asLAST_30_DAYS) has them divided by it, one that states none counts as one day, andcost/spendare one figure. -
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 onlyAdTrackingRecord) 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 Conventionand parsed by mureo, not interpreted by the agent. Exposed asanalysis_tracking_consistency_check, used by/tracking-healthfor 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. -
Rollback with allow-list gating —
mureo/rollback/turns agent-authoredreversible_paramshints into concreteRollbackPlanrecords.reversible_paramsis 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. Themureo rollback list/showCLI 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_enddeclare the boundary, everyaction_logentry written in between is stamped with the batch id at the singleappend_action_logchoke point (so native, hosted-connector and bridged/plugin recordings all join without any per-platform code), androllback_plan_gettakes that id and classifies every member. Coverage is reported overall and per platform asfull/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, markedorigin: "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 reportspartialcoverage rather than promising a full revert. Platforms with no feed are reportedchange_import_unavailable_for_<platform>, never as “no changes” — see change-import.md.
See SECURITY.md for the full threat model.
Mixin Architecture
Section titled “Mixin Architecture”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.
Google Ads Client — Mixins
Section titled “Google Ads Client — Mixins”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 viarun_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 catchesGoogleAdsExceptionand returns user-friendly messages - Partial reads: in a composite result (diagnostics, performance analysis, monitoring, budget reallocation) a read that fails sets its key to
Noneand logsFailed to retrieve <what> (<ExceptionClass>)at WARNING; derived insights skip aNoneinput, and agoogle_ads_monitoring_*verdict that rests on a failed read isstatus: "unknown"unless data that was read provescritical(#865)
Meta Ads Client — 17 Mixins
Section titled “Meta Ads Client — 17 Mixins”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 withact_) - 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)
MCP Server Tool Dispatch Flow
Section titled “MCP Server Tool Dispatch Flow”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:
-
Tool definitions are
mcp.types.Toolobjects withinputSchema(JSON Schema). -
Handler dispatch uses a
dict[str, Callable]mapping tool names to async handler functions. -
Error handling: the
@api_error_handlerdecorator catches exceptions and converts them toTextContenterror messages, so the agent always gets a text response. -
Credential loading happens per-request. Each handler call loads credentials from file/env, creates a fresh client, and executes the operation.
-
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-partymureo.providersentry-point plugins and the in-tree Amazon Ads bridge (mureo/amazon_ads/, registered byregister_amazon_provider()before entry-point discovery so the in-tree bridge wins theamazon_adsname) ride this one path, so they get the same audit / throttle / strategy-gate /action_logtreatment. The bridge’smcp_tools()is pure — it reads~/.mureo/amazon_tools.jsononly, with no credentials and no network — so a missing or broken manifest means “no Amazon tools”, never a startup failure. -
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 importingmureo.mcp.servermust stay cheap. Measured on one development machine on 2026-10-08 as the import’s own CPU (time.process_timeinside 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 insys.modules; it now costs 1.1 s and leaves 882. With amureo.runtime_context_factoryplugin 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 (seedocs/plugin-authoring.md). Two rules keep the server’s own share where it is, both enforced bytests/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_adspackages 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.pyimports its client underTYPE_CHECKINGplus one function-local import for the constructor’sisinstancecheck. 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 theRuntimeContext, 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: asys.meta_pathfinder 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.LazyToolValidatorscompiles a tool’sinputSchemavalidator on that tool’s first call, which is the call it is enforced on.check_schemacosts 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 ajsonschema.validate()that runs the metaschema check itself, and turns any exception from it into an error result: for a schema that declares no$schemaor declares Draft 2020-12 (mcppicks 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 — noToolSchemaUnusableError, no log record, noPluginToolWarning. mureo’s layer is the only check on anmcpwithoutvalidate_input(pyproject.tomlallowsmcp>=1.8.0,<2), on a directhandle_call_toolcall, 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 bytests/test_mcp_strict_input_schemas.py, against both the authored schema and the one the server actually compiles (they differ:_reason_paramrebuilds 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 throughasyncio.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, whichgoogle_ads_accounts_listuses when nocustomer_idis given, runs its SDK import and gRPC calls in a worker thread the same way. The analytics modules’ period-over-period windows come frommureo/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 inhandle_mcp_toolgoes throughasyncio.to_thread(seedocs/plugin-authoring.md). On the same development machine (2026-10-09,time.process_timein a child interpreter right afterimport 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 takessys.modulesfrom 882 to 2228. The Meta client costs under 10 ms after it. - No platform SDK or API client may be asked for on mureo’s import path. The
Rate Limiting
Section titled “Rate Limiting”mureo includes a built-in rate limiter (mureo/throttle.py) to prevent API bans caused by high-speed requests from AI agents.
Algorithm
Section titled “Algorithm”Each platform throttler combines two mechanisms:
- Token bucket — controls instantaneous QPS (queries per second) with configurable burst allowance.
- Rolling hourly cap — enforces a hard ceiling on total requests per hour.
Default Limits
Section titled “Default Limits”| Platform | QPS | Burst | Hourly Limit | Notes |
|---|---|---|---|---|
| Google Ads | 10 | 5 | (none) | Conservative defaults; Google uses dynamic server-side limits |
| Meta Ads | 20 | 10 | 50,000 | Tuned to stay within the Business Use Case (BUC) quota |
| Search Console | 5 | 5 | (none) | Reuses Google OAuth2 credentials |
| Plugin / bridge tools | 5 | 5 | (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 |
Integration
Section titled “Integration”- Module-level singletons — one
Throttlerinstance per platform, shared across all MCP tool calls in the same process. Google Ads’ isGOOGLE_ADS_THROTTLERand Meta Ads’ isMETA_ADS_THROTTLER, both inmureo/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.
Authentication Flow
Section titled “Authentication Flow”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.
Meta Ads Token Auto-Refresh
Section titled “Meta Ads Token Auto-Refresh”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.
Amazon Ads Token Minting and Refresh
Section titled “Amazon Ads Token Minting and Refresh”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.
Command-Based Workflow System
Section titled “Command-Based Workflow System”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.
How It Works
Section titled “How It Works”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 reportCommands
Section titled “Commands”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.
| Command | Purpose | Strategy Sections Used |
|---|---|---|
/onboard | Platform discovery + strategy generation | (generates all sections) |
/daily-check | Cross-platform health monitoring | Operation Mode, Data Sources |
/rescue | Emergency performance fix with site-side diagnosis | All sections |
/search-term-cleanup | Keyword hygiene with paid/organic overlap | Persona, USP, Data Sources |
/creative-refresh | Multi-platform creative refresh | Persona, USP, Brand Voice, Data Sources |
/budget-rebalance | Cross-platform budget optimization | Operation Mode, Goals, Data Sources |
/competitive-scan | Paid + organic competitive analysis | Market Context, Data Sources |
/goal-review | Multi-source goal evaluation | Operation Mode, Goals, Data Sources |
/weekly-report | Cross-platform weekly report | All sections |
/sync-state | Multi-platform state synchronization | (writes STATE.json) |
/learn | Save 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.