Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
Expand All @@ -28,6 +21,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
their app secret — and writes to other `meta_ads` fields leave the stamp
alone, since it still describes the token on disk.

- **A platform whose credentials failed still shipped a `/daily-check`
report that looked complete.** When a token had expired or no
credential was configured, the run went through every step and
delivered a report with error prose — `"API error: Meta API request
failed (status=400, ...)"` or `"Credentials not found. Set environment
variable ..."` — sitting where the numbers belonged, next to real
figures from the platforms that did answer, followed by the usual
recommendations. Nothing marked the report partial, so a reader
skimming it concluded the platform had been quiet rather than
unreadable.

The cause was that an auth failure arrived as an ordinary *successful*
tool result whose text happened to be a sentence about credentials, so
nothing downstream could tell "no spend" from "could not read". Auth
failure is now a first-class, machine-readable outcome on **every**
platform: the result carries `{"status": "auth_error", "auth_cause":
"no_credentials" | "token_invalid", "detail": ...}`, reusing the
`status`-field convention `blind_spots` and the delivery-collapse
report already use rather than adding a second one. `detail` keeps the
operator-facing sentence, so nothing readable is lost. The two causes
are separated because their recovery differs — configure the
credential vs. re-authorize a rejected one.

It is produced in the two places every platform routes through, so
Google Ads, Meta Ads and Search Console behave identically:
`_no_creds_result` for a missing credential, and `@api_error_handler`
for a rejected one (an HTTP 401/403, a Google Ads
`authentication_error` / `authorization_error`, or Meta's
`OAuthException` — Meta answers an expired token with HTTP 400, so the
Meta client now names that case explicitly instead of letting it
flatten into a generic API error). Classification is deliberately
narrow: an unrecognized failure stays an ordinary `API error:`, since
mislabelling a quota or validation error would send an operator to
re-authorize a healthy account.

`/daily-check` gained the branch it was missing. On an `auth_error` it
now marks the report **partial** in its opening line, names each
affected platform with its cause and recovery, withholds every
verdict, goal-progress line and recommendation that depends on the
missing platform's data, and still completes for every platform that
did answer.

The shared `is_error_result` detector recognises the new envelope too,
so a mutation refused for a missing or rejected credential is still
kept out of `action_log` — it would otherwise have been recorded as a
change that never happened, complete with an `observation_due`.

### Added

- **A Meta token expiry notice and a re-authenticate control on the Meta
Expand Down
18 changes: 14 additions & 4 deletions docs/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -1026,26 +1026,36 @@ The `text` field contains a JSON string that your agent should parse.

### Authentication Errors

If credentials are missing, tools return a descriptive error message (not an exception):
An auth failure is returned as a result, not raised as an exception — but it is a **structured, machine-readable outcome**, not prose. Every platform uses the same envelope:

```json
[
{
"type": "text",
"text": "Authentication credentials not found. Set environment variables or ~/.mureo/credentials.json."
"text": "{\"status\": \"auth_error\", \"auth_cause\": \"no_credentials\", \"detail\": \"Credentials not found. Set environment variable (META_ADS_ACCESS_TOKEN) or configure ~/.mureo/credentials.json.\"}"
}
]
```

| Field | Meaning |
|-------|---------|
| `status` | Always `auth_error`. This is the marker to branch on: mureo could not read this platform at all, so it produced **no data** for this call. |
| `auth_cause` | `no_credentials` — nothing is configured for this platform. `token_invalid` — a credential exists and the platform rejected it (expired or revoked token, withdrawn permission). The two have different recovery actions. |
| `detail` | The operator-facing sentence: which environment variable to set, or what the platform said. |

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

**An agent must never render an `auth_error` result as data.** A platform that could not be read is not a platform that was quiet, and a report containing one is partial — see the `/daily-check` skill's partial-report rule. mureo also treats the envelope as a failed call internally: a mutation that returns it is never written to `action_log`.

### API Errors

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

```json
[
{
"type": "text",
"text": "API Error: Meta API request failed (status=400, path=/act_123/campaigns)"
"text": "API error: Meta API request failed (status=400, path=/act_123/campaigns)"
}
]
```
Expand Down
10 changes: 9 additions & 1 deletion mureo/_data/skills/_mureo-shared/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,12 +482,20 @@ All tools return structured JSON via `TextContent`. The format depends on the to

### Authentication Error

Every platform returns the SAME envelope when mureo could not authenticate. It is a successful tool call, so you must branch on the payload:

```json
{
"error": "No credentials found. Set environment variables (GOOGLE_ADS_DEVELOPER_TOKEN, ...) or ~/.mureo/credentials.json"
"status": "auth_error",
"auth_cause": "no_credentials",
"detail": "Credentials not found. Set environment variables (GOOGLE_ADS_DEVELOPER_TOKEN, ...) or ~/.mureo/credentials.json."
}
```

`auth_cause` is `no_credentials` (nothing is configured — the operator runs `mureo configure` / `mureo auth setup`) or `token_invalid` (a credential exists and the platform rejected it, typically an expired token — the operator re-authorizes). `detail` is the operator-facing sentence.

**This is a hole in your data, not a data point.** mureo read *nothing* from that platform for this call, so never print `detail` where a number belongs and never treat that platform's missing or empty figures as evidence that it was quiet. Say which platform failed and why, mark any report containing one as **partial**, and withhold conclusions that depend on the numbers you did not get.

## STATE.json Schema (when writing on Code via `Write`)

> **Tool output ≠ STATE.json.** The *Success Response* above is what a vendor
Expand Down
3 changes: 3 additions & 0 deletions mureo/_data/skills/daily-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Step 2b **imports** what mureo can fetch, so much of that work is now in `action
- mureo BYOD mode applies only to mureo native tools — do **not** look for raw CSVs in the project directory; mureo BYOD data is centralized in the workspace `byod/` directory (or `~/.mureo/byod/` for legacy CLI users) and is only accessible through mureo MCP tools.

4. **Platform health checks**: Run health diagnostics on each configured ad platform.
- **Auth failure is not data — it is a hole in the report.** Any tool result carrying `"status": "auth_error"` means mureo could not read that platform in steps 3–4 at all: `auth_cause` is `no_credentials` (nothing is configured) or `token_invalid` (a credential exists and the platform rejected it — typically an expired token), and `detail` is the operator-facing sentence. **Never render `detail` where a metric belongs, and never read that platform's missing or empty numbers as "quiet"** — unreadable and quiet are different facts. Record the platform name with its `auth_cause` and carry both into step 10 exactly as step 2b's `blind_spots` are carried, then keep going for every other platform: one platform's credentials failing degrades the report, it never aborts the run.
- **Analytics-first (both modes' first move).** Consult `mureo_analytics_modules_list` for **every** platform (not just external ones) — it maps each platform to its advertised capabilities (`detect_anomalies`, `diagnose_performance`, `audit_creative`, `analyze_budget_efficiency`, `detect_delivery_collapse`). Match its `platform` field against the platform keys you already hold: it is the **canonical platform key** (`plugin:<dist>:<provider>` for a plugin, and the older `plugin:<dist>` still resolves), the same key STATE.json and `action_log` use, while `registry_name` / `source_distribution` are identifiers, not keys — never look a platform up by them (see `../_mureo-shared/SKILL.md` → *Canonical platform key*). Then per platform:
- **Module registered AND advertises the capability you need** → run it via `mureo_analytics_run` (`DETECT_ANOMALIES` / `DIAGNOSE_PERFORMANCE`; pass `platform`, `capability`, `account_id`; add `window_days` for `detect_anomalies` or `scope` for `diagnose_performance`) and fold its **compact findings** (`result`) into the health summary. In **incremental** mode this REPLACES the raw pulls — do **not** ALSO fetch raw campaign / insights rows to re-derive what the module already summarized. A `status` other than `ok` (`no_analytics_module` / `capability_not_available` / `error`) means treat that capability as unavailable and fall through to the raw checks below — never fail the whole daily-check because one platform's module errored.
- **No analytics module, or the module lacks the capability** → report `analytics_not_available_for_<platform>` for the **module-specific** deep diagnostics (RSA audit, `result_indicator`, budget-efficiency) — `<platform>` being the canonical platform key, so the notice names the same platform as the rest of the report — and do NOT fabricate heuristics from the integration's tool schemas (Issue #120). **BUT still run the generic, platform-agnostic `analysis_anomalies_check` per campaign** (metrics normalized to `campaign_id`/`cost`/`clicks`/`impressions`/`conversions`/`cpa`/`ctr`): it builds a median baseline from `action_log` and flags zero-spend / CPA-spike / CTR-drop for TikTok, plugins and official-MCP platforms too — a principled detector, not an auto-derived heuristic (see `_mureo-shared` → *analytics-module parity* → generic anomaly check).
Expand Down Expand Up @@ -106,6 +107,8 @@ Step 2b **imports** what mureo can fetch, so much of that work is now in `action

10. **Report** — the mode decides the shape:

**Partial report (both modes) — this goes FIRST, above the verdicts.** If any platform came back `"status": "auth_error"` in steps 3–4, this report is **partial**: open it by saying so, naming each affected platform with its `auth_cause` and the recovery (`no_credentials` → configure the credential via `mureo configure` / `mureo auth setup`; `token_invalid` → re-authorize, the credential is present and dead). Then **withhold every verdict, goal-progress line and recommendation that depends on that platform's numbers** — an unreadable platform has no performance to judge, and cross-platform advice (budget shifts, "scale here, cut there") derived only from the platforms that did answer is not the advice you would give with all of them. Report the platforms that did answer as usual, and make clear which is which.

**Deep mode — full report.** Summarize findings as: **Healthy** (no action needed), **Watch** (minor issues to monitor), **Action needed** (requires immediate attention). For each issue, suggest specific actions aligned with the current Operation Mode. Do NOT recommend actions based on single-day fluctuations — at least 7 consecutive days of critical metrics (>30% off target) before suggesting rescue.

**Incremental mode — diff-first.** Compare this run against the previous `reports.daily` summary and report ONLY the deltas:
Expand Down
165 changes: 165 additions & 0 deletions mureo/core/auth_failure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Auth failure as a first-class outcome, on every platform (#580).

A platform whose credentials are missing, or whose token has expired, used
to answer a read with an ordinary *successful* MCP result whose text
happened to be a sentence about credentials — ``"Credentials not found. Set
environment variable ..."`` or ``"API error: Meta API request failed
(status=400, ...)"``. Nothing downstream could tell that apart from any
other error, let alone from real data, so a report skill folded the prose in
next to real figures and shipped a report that looked complete. "Could not
read" and "nothing to read" must never look alike.

This module holds the one vocabulary that makes them different:

- :data:`AUTH_ERROR_STATUS` — the ``status`` value stamped on the payload.
It reuses the ``status``-field convention the rest of mureo already uses
for exactly this "what did I fail to see" question (``blind_spots`` /
``ChangeImportStatus`` in the change-import feed, ``no_credentials`` /
``data_unavailable`` on :class:`~mureo.analytics.models.DeliveryCollapseReport`)
rather than inventing a second convention a skill would have to learn.
- :data:`AUTH_CAUSE_NO_CREDENTIALS` / :data:`AUTH_CAUSE_TOKEN_INVALID` — the
two causes, because they have different recovery actions: one was never
configured, the other was configured and rejected.

It lives in ``mureo.core`` and not next to the MCP handlers because the
platform clients raise :class:`PlatformAuthError` and the MCP layer renders
it; a client importing from ``mureo.mcp`` would invert the layering.
"""

from __future__ import annotations

from typing import Any

#: ``status`` value every auth-failure payload carries, whatever the
#: platform. This is the marker a skill keys on: one string, one meaning —
#: mureo could not read this platform at all, so it has no numbers for this
#: run and its silence is not evidence of anything.
AUTH_ERROR_STATUS = "auth_error"

#: No credential is configured for the platform at all.
AUTH_CAUSE_NO_CREDENTIALS = "no_credentials"

#: A credential exists and the platform rejected it — an expired or revoked
#: token, a withdrawn permission. Distinct from the above because the
#: recovery differs: configure vs. re-authorize.
AUTH_CAUSE_TOKEN_INVALID = "token_invalid"

#: The closed vocabulary. A cause outside it would reach a skill that has no
#: branch for it, which is the failure this module exists to end.
AUTH_CAUSES = frozenset({AUTH_CAUSE_NO_CREDENTIALS, AUTH_CAUSE_TOKEN_INVALID})

#: HTTP statuses that mean "the credential was rejected" rather than "the
#: request was wrong". Deliberately narrow: labelling a 400/429/500 as an
#: auth failure would send an operator to re-authorize a healthy account and
#: would withhold a report section for no reason.
_AUTH_HTTP_STATUSES = frozenset({401, 403})

#: The Google Ads ``error_code`` oneof names that mean the credential, not
#: the request, was refused.
_GOOGLE_ADS_AUTH_ONEOFS = frozenset({"authentication_error", "authorization_error"})

#: How far up the ``__cause__`` / ``__context__`` chain to look. Clients
#: re-raise as ``RuntimeError(...) from exc``, so the auth signal is usually
#: one or two links down; the bound stops a self-referential chain.
_MAX_CHAIN_DEPTH = 5


class PlatformAuthError(RuntimeError):
"""A platform refused mureo's credential.

Raised at the point where a client can actually tell an auth failure
from a bad request — which is platform-specific knowledge (Meta answers
an expired token with HTTP 400 and an ``OAuthException`` body, not a
401), so it belongs in the client rather than in a downstream sniffer.
"""

def __init__(self, message: str, *, cause: str = AUTH_CAUSE_TOKEN_INVALID) -> None:
super().__init__(message)
self.cause = cause if cause in AUTH_CAUSES else AUTH_CAUSE_TOKEN_INVALID


def auth_failure_payload(cause: str, detail: str) -> dict[str, Any]:
"""Build the one payload shape every platform's auth failure carries.

``detail`` keeps the operator-facing sentence (which env var to set,
what the platform said) so nothing readable is lost — but it is a
*field*, never the whole answer, so no reader can mistake it for data.
"""
if cause not in AUTH_CAUSES:
raise ValueError(
f"Unknown auth cause {cause!r}; expected one of {sorted(AUTH_CAUSES)}"
)
return {"status": AUTH_ERROR_STATUS, "auth_cause": cause, "detail": detail}


def is_auth_failure_payload(payload: object) -> bool:
"""True if ``payload`` is an auth-failure payload."""
return isinstance(payload, dict) and payload.get("status") == AUTH_ERROR_STATUS


def _http_status(exc: BaseException) -> int | None:
"""The HTTP status an ``httpx.HTTPStatusError``-shaped exception carries.

Duck-typed rather than imported so this module stays free of every
client's dependency; the attribute path is the same for httpx and for
the SDK wrappers that mimic it.
"""
status = getattr(getattr(exc, "response", None), "status_code", None)
return status if isinstance(status, int) else None


def _is_google_ads_auth_failure(exc: BaseException) -> bool:
"""True for a ``GoogleAdsException`` whose failure is an auth failure.

The ``error_code`` field is a protobuf oneof, so an unset member still
reads back as its zero enum value — ``WhichOneof`` is the only reliable
way to ask which error kind was actually set.
"""
errors = getattr(getattr(exc, "failure", None), "errors", None) or ()
for error in errors:
which = getattr(getattr(error, "error_code", None), "WhichOneof", None)
if which is None:
continue
try:
name = which("error_code")
except Exception: # noqa: BLE001 - classification must never itself raise
continue
if name in _GOOGLE_ADS_AUTH_ONEOFS:
return True
return False


def classify_auth_exception(exc: BaseException | None) -> str | None:
"""Return the auth cause behind ``exc``, or ``None`` if it is not one.

``None`` is the safe answer: an unclassified failure stays an ordinary
API error, which is what it was before this existed. Over-claiming is
the expensive direction — it would withhold a report section and send
the operator to fix credentials that are fine.
"""
seen: set[int] = set()
current: BaseException | None = exc
for _ in range(_MAX_CHAIN_DEPTH):
if current is None or id(current) in seen:
return None
seen.add(id(current))
if isinstance(current, PlatformAuthError):
return current.cause
if _http_status(current) in _AUTH_HTTP_STATUSES:
return AUTH_CAUSE_TOKEN_INVALID
if _is_google_ads_auth_failure(current):
return AUTH_CAUSE_TOKEN_INVALID
current = current.__cause__ or current.__context__
return None


__all__ = [
"AUTH_CAUSES",
"AUTH_CAUSE_NO_CREDENTIALS",
"AUTH_CAUSE_TOKEN_INVALID",
"AUTH_ERROR_STATUS",
"PlatformAuthError",
"auth_failure_payload",
"classify_auth_exception",
"is_auth_failure_payload",
]
Loading