Skip to main content
Glama
mlreymendez

MCP Meta Ads

by mlreymendez
README.md
# MCP Meta Ads

Servidor MCP para **preparar, validar y crear** campañas de Meta Ads desde un
brief en castellano. El contrato está congelado en
[`CAMPAIGN_SPEC.md`](CAMPAIGN_SPEC.md): ese documento manda, este README solo
cuenta cómo está implementado.

Estado: núcleo completo con la suite de tests de la sección 5 en verde.
Nunca se corrió contra la Marketing API real — la sección 6 del spec (log de
errores de Meta) sigue vacía justamente por eso.

## Las 6 reglas invariantes, y dónde viven en el código

| Regla | Dónde se hace cumplir |
|---|---|
| 1. Todo se crea en `PAUSED` | `payloads.py` fuerza `status=PAUSED` en los 4 payloads; `validate.py` recorre el spec entero (`_statuses_recursivos`) y rechaza cualquier otro status antes de tocar la API |
| 2. `ad_account_id` nunca se infiere | `profiles.ProfileStore.resolver()`: con 2+ cuentas y sin alias, `AmbiguousAccountError` con la lista de cuentas |
| 3. El token no vive en el repo | El perfil guarda `token_ref` (nombre de la variable de entorno); `AccountProfile.token()` la lee en el momento y no la cachea. Un test recorre el repo buscando algo con forma de token |
| 4. Desarrollo va contra sandbox | `AccountProfile.exigir_sandbox()` corre antes de cada llamada; solo `META_ADS_ALLOW_PRODUCTION=1` la levanta, y habilitarla no es tarea de un agente |
| 5. Presupuestos enteros en unidades menores | `validate.py` rechaza floats y strings; `payloads.py` manda `str(int(...))` |
| 6. `preparar` nunca escribe en Meta | `spec.py` no importa el cliente HTTP; un test rompe `urllib.request.urlopen` mientras corre `preparar` |

## Estructura

```
meta_ads/
  errors.py        errores con `code` estable + mensaje accionable
  enums.py         objetivos, compatibilidades, CTAs, mínimos por moneda
  profiles.py      perfil de cuentas, resolución de cuenta, token, sandbox
  brief.py         brief en castellano -> hechos (nada que el brief no diga)
  spec.py          armado del Campaign Spec + provenance + warnings
  validate.py      validación local (2 severidades) + validate_only contra Meta
  payloads.py      spec -> payloads de la Marketing API (un solo lugar)
  meta_client.py   cliente HTTP (solo stdlib, urllib)
  create.py        creación de los 4 objetos + rollback
  server.py        adaptador MCP (mcp 1.x y 2.x)
tests/             80 tests, sin red, stdlib
```

El núcleo **no tiene dependencias**: `pip install -r requirements.txt` solo hace
falta para exponerlo como servidor MCP.

## Uso

```bash
cp profiles.example.json profiles.json     # completar ids reales (sandbox)
cp .env.example .env                       # y exportar SECRET_META_TOKEN_OWN
export META_ADS_PROFILES=$PWD/profiles.json
python3 -m meta_ads.server                 # stdio
./correr_tests.sh                          # la suite entera, sin red
```

Los tools se registran con nombre ASCII (`preparar_campana`, `validar_campana`,
`crear_campana`) porque MCP no admite `ñ` en los identificadores; son los
`preparar_campaña` / `validar_campaña` / `crear_campaña` del documento. Hay un
cuarto tool, `listar_cuentas`, que no toca Meta: sirve para que el agente pueda
preguntar "¿cuál de estas?" cuando la regla 2 lo obliga a preguntar.

### El copy no lo inventa el servidor

`preparar_campana(brief, account_alias?, creatives?)` acepta `creatives` como
lista de dicts escritos por el agente. Si no vienen, el spec sale con
`primary_text` / `headline` en `null`, provenance `pending` y un warning;
`validar` y `crear` los exigen completos. Es la división honesta: el spec
resuelve estructura, el agente escribe texto. Lo mismo con los assets:
`image_hash` / `video_id` entran ya resueltos, el spec no sube nada.

## Desviaciones del spec congelado

Dos campos aditivos y opcionales (un spec sin ellos sigue siendo válido):

- **`preset`** (raíz): nombre del preset aplicado. Sin esto, las reglas 11-13
  de la sección 5 no son verificables. Si falta, `validate.py` deduce el preset
  comparando `objective` + `optimization_goal` + `destination_type` contra los
  presets del perfil.
- **`ad_set.destination_type`**: los presets ya lo definen y la API lo pide.

Y dos valores de `provenance` que el documento no enumera: `pending` (copy que
falta escribir) y `brief` aplicado a `account.resolved_by` cuando el propio
brief nombra la cuenta.

## Lo que todavía no está probado contra Meta

Esto es lo que hay que mirar primero cuando haya token de sandbox:

1. **`validate_only` del ad set.** Meta exige `campaign_id` para validar un ad
   set, y en `preparar` la campaña todavía no existe. Hoy se valida la campaña
   contra la API y el resto solo localmente; si el spec trae
   `existing.campaign_id`, también se valida el ad set. Ver `validar_campana`.
2. **Click-to-WhatsApp.** `payloads.creative_payload` arma
   `call_to_action.value.app_destination = "WHATSAPP"` con
   `link = https://api.whatsapp.com/send`. Es la forma más documentada, no está
   confirmada contra la cuenta.
3. **Mínimos por moneda** (`enums.MIN_DIARIO_POR_MONEDA_MINOR`): solo el USD
   sale de documentación. El resto son valores conservadores. El mínimo
   efectivo es `max(mínimo de moneda, limits.daily_budget_min_minor)`, así que
   el techo real lo pone el perfil.
4. **Nombres de enum** en general: la tabla `COMPATIBILIDAD` es una foto y
   `validate_only` es el árbitro. Cada `Invalid parameter` que aparezca se
   corrige en `enums.py` **y** se anota en la sección 6 del `CAMPAIGN_SPEC.md`.

## Mapa de los casos de test de la sección 5

| Caso | Test |
|---|---|
| 1. Brief mínimo, una cuenta | `test_preparar.Caso1BriefMinimo` |
| 2. Brief que sobreescribe defaults | `test_preparar.Caso2BriefSobreescribeDefaults` |
| 3. Spec válido → `validar` ok sin crear | `test_validar.Caso3SpecValido` |
| 4. `crear` → 4 objetos en PAUSED | `test_crear.Caso4CreacionCompleta` |
| 5. Sin cuenta habiendo dos | `test_preparar.Caso5CuentaAmbigua` |
| 6. Presupuesto fuera de límites | `test_preparar.Caso6PresupuestoFueraDeLimites`, `test_validar.Caso6PresupuestoEnElSpec` |
| 7. Cualquier `status: ACTIVE` | `test_validar.Caso7StatusActive` |
| 8. Combinación objective/goal/billing inválida | `test_validar.Caso8CombinacionInvalida` |
| 9. `creative_ref` inexistente | `test_validar.Caso9CreativeRefInexistente` |
| 10. Fallo en el POST del ad → rollback | `test_crear.Caso10RollbackAlFallarElAd` |
| 11. `wsp_leads` con `link_url` | `test_presets.Caso11WspConLinkUrl` |
| 12. `web_conversions` sin pixel / evento | `test_validar.Caso12PromotedObjectIncompleto` |
| 13. `wsp_leads` con cta que no es WhatsApp | `test_presets.Caso13WspConCtaEquivocada` |
| 14. `wsp_leads` de punta a punta | `test_presets.Caso14PresetWspLeads` |
| 15. `web_conversions` de punta a punta | `test_presets.Caso15PresetWebConversions` |

`test_invariantes.py` cubre las 6 reglas de la sección 1 y el cliente HTTP;
`test_server.py`, el contrato de los handlers MCP.

Los casos que "deben fallar" se verifican en las dos puertas: `preparar` los
rechaza al armar el spec y `validar`/`crear` los rechazan también si alguien
edita el spec a mano — con la comprobación extra de que la API no se llamó.