Skip to main content
Glama
wallacymenezes

mcp-meta-ads

README.md
# mcp-1nort-meta

Servidor MCP que expõe a Meta Marketing API ao Claude: relatórios de desempenho de anúncios e
ajustes operacionais (pausar/reativar, alterar orçamento), para as contas dos clientes da 1nort.

Roda local, por stdio, em TypeScript sobre Node 20+.

> **O token configurado é de produção.** Ele é de um usuário de sistema do Business Manager e
> alcança as contas de **todos** os clientes da agência. A allowlist de contas é o que restringe
> este servidor a um subconjunto — leia [docs/seguranca-mutacoes.md](docs/seguranca-mutacoes.md)
> antes de liberar escrita.

## Começando

```bash
npm ci
cp .env.example .env      # e preencha
npm run check             # typecheck + testes
npm run build
```

O `.env` **precisa** ter `META_AD_ACCOUNT_ALLOWLIST` preenchida. Sem ela o servidor não inicia — de
propósito: vazio nunca significa "liberar todas as contas".

### Rodar e inspecionar

```bash
npm run inspector    # abre o MCP Inspector apontando para o servidor
npm run dev          # roda direto do TypeScript, sem build
npm start            # roda o build de dist/
```

### Registrar no Claude Code

O [`.mcp.json`](.mcp.json) na raiz já registra dois servidores (só caminhos, nenhum segredo):

- **`meta-ads`** — respeita o `.env`.
- **`meta-ads-somente-leitura`** — força `META_READ_ONLY=true`. Como o ambiente do processo tem
  precedência sobre o `--env-file`, este é somente-leitura por construção, independente do que o
  `.env` diga. **Use este como padrão** e deixe o outro para sessões deliberadas de alteração.

Ou pela linha de comando:

```bash
claude mcp add --scope project meta-ads -- node --env-file=.env dist/index.js
```

No Claude Desktop, o `claude_desktop_config.json` precisa de caminhos **absolutos** (Desktop não tem
raiz de projeto).

## Ferramentas

### Leitura (sempre disponíveis)

| Ferramenta              | O que faz                                                                                                                  |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `meta_server_status`    | Guard-rails em vigor: contas permitidas, modo, limites. Zero chamadas à Meta. Consulte antes de propor qualquer alteração. |
| `meta_list_ad_accounts` | As contas da allowlist, com moeda, fuso e orçamento mínimo.                                                                |
| `meta_get_insights`     | Métricas por conta/campanha/conjunto/anúncio, uma linha por dia por padrão.                                                |
| `meta_list_campaigns`   | Campanhas com status, objetivo e orçamento.                                                                                |
| `meta_list_ad_sets`     | Conjuntos, opcionalmente de uma campanha específica.                                                                       |
| `meta_list_ads`         | Anúncios com miniatura em 512px; `include_media` resolve a URL do vídeo.                                                   |

### Banco (só com `DB_FERRAMENTAS_MCP=true`)

Leem o banco da 1nort com um usuário SELECT-only. Histórico já coletado, sem consumir cota da Meta.

| Ferramenta            | O que faz                                                                     |
| --------------------- | ----------------------------------------------------------------------------- |
| `db_scope_summary`    | Quantas contas o escopo alcança e até que dia o banco tem dado.               |
| `db_list_clients`     | Nome e segmento do cliente de cada conta — traduz "cliente X" em id de conta. |
| `db_account_daily`    | Série diária de uma conta a partir do histórico coletado.                     |
| `db_collector_health` | Se a coleta de uma conta está bloqueada e até onde chegou.                    |
| `db_top_ads`          | Ranking de anúncios do período.                                               |

**Nenhuma delas aceita SQL, e nenhuma aceita e-mail.** SQL livre não tem como ser seguro aqui: o
usuário somente-leitura enxerga `tb_credenciais` (com `token_kommo`, `token_ghl`, `pixel_token`) e a
própria `tb_meta_ads` tem `pixel_token`; um agente que lê nome de campanha escrito por cliente é superfície
de injeção. Filtrar SQL na aplicação não resolve (CTE, subquery, `pg_catalog`, `::regclass`), e a
restrição correta — `REVOKE` no Postgres — depende de um direito que não temos. E como nenhuma
ferramenta aceita e-mail, **o modelo não consegue trocar o próprio escopo**: isso é ação do humano
na tela.

### Escrita (só com `META_READ_ONLY=false`)

| Ferramenta             | O que faz                                                             |
| ---------------------- | --------------------------------------------------------------------- |
| `meta_update_status`   | Pausa ou reativa campanha, conjunto ou anúncio.                       |
| `meta_update_budget`   | Altera orçamento diário ou total de campanha ou conjunto.             |
| `meta_create_campaign` | Cria campanha. Nasce **pausada**.                                     |
| `meta_create_ad_set`   | Cria conjunto numa campanha existente. Nasce **pausado**.             |
| `meta_create_ad`       | Cria anúncio a partir de publicação, criativo salvo ou URL de imagem. |
| `meta_create_audience` | Cria público personalizado (Instagram, página, site ou formulário).   |

E três de leitura que existem para o modelo não inventar id: `meta_list_creatives`,
`meta_list_publications` e `meta_list_audiences`.

**Tudo nasce pausado, e isso não é parâmetro.** Nenhuma ferramenta de criação aceita `status`: um
objeto pausado não gasta, então errar custa zero, e ativar continua sendo `meta_update_status` —
auditado e com confirmação do host.

**`special_ad_categories` é constante `[]`, fora do schema.** A 1nort declara que não anuncia
crédito, emprego, moradia nem tema social. Se isso mudar — financiamento solar seria `CREDIT` —, o
valor está em `SEM_CATEGORIA_ESPECIAL`, num lugar só, e alterá-lo é decisão de um humano. Declarar
categoria especial errada é violação de política da Meta, não erro de campanha.

Não existe exclusão nem arquivamento. `DELETED` e `ARCHIVED` não estão no schema, então não há
caminho de código que chegue a eles — objeto criado por engano se apaga à mão no Gerenciador.

## Duas regras de negócio que não podem ser afrouxadas

**1. Leads de formulário e de mensagem nunca se somam.**

```
leads_form     ← onsite_conversion.lead_grouped
leads_message  ← onsite_conversion.messaging_conversation_started_7d
```

O action type genérico `lead` é **ignorado** — conta formulário por outro critério e diverge de
`lead_grouped` por ordem de grandeza. Ausência devolve `null`, nunca `0`: "não medido" e "medido e
deu zero" são fatos diferentes. Regra portada de `ExtratorAcoesMeta.java`, do projeto Citrino, para
que os relatórios dos dois sistemas não divirjam para o mesmo cliente no mesmo dia.

**2. Orçamento é informado na unidade MAIOR da moeda.**

`amount: 250.50` significa R$ 250,50. A Meta armazena em unidade menor (`25050`) e a conversão é do
servidor. Mandar centavos aqui definiria um orçamento cem vezes maior — por isso a resposta sempre
mostra as duas unidades lado a lado, para o erro saltar aos olhos antes da confirmação.

## Segurança

Resumo; o contrato completo está em [docs/seguranca-mutacoes.md](docs/seguranca-mutacoes.md).

- Allowlist de contas, com comparação exata e tipo _branded_ que o compilador cobra.
- Vínculo objeto → conta antes de toda escrita: `POST /{id}` não menciona conta nenhuma, então sem
  essa releitura a allowlist não valeria nada na escrita.
- Duas chaves para aplicar: `META_WRITE_DRY_RUN=false` **e** `confirm: true`. Prévia e aplicação
  percorrem o mesmo código; muda só o `validate_only`, que é validado pela **própria Meta**.
- Limites de orçamento com falha fechada; moeda fora da tabela é recusada, nunca chutada.
- Auditoria de toda tentativa, inclusive recusas, em `stderr`.
- Token só no header `Authorization`; `appsecret_proof` em toda chamada.

### Variáveis de ambiente

Ver [`.env.example`](.env.example). As que mais importam:

| Variável                             | Padrão     | Efeito                                                                                   |
| ------------------------------------ | ---------- | ---------------------------------------------------------------------------------------- |
| `META_AD_ACCOUNT_ALLOWLIST`          | _(nenhum)_ | **Obrigatória.** Sem ela o servidor não inicia.                                          |
| `META_READ_ONLY`                     | `true`     | Ferramentas de escrita nem são registradas.                                              |
| `META_WRITE_DRY_RUN`                 | `true`     | Toda escrita vira `validate_only`.                                                       |
| `META_MAX_BUDGET_CHANGE_PCT`         | `50`       | Variação máxima num ajuste.                                                              |
| `META_MAX_DAILY_BUDGET`              | `1000`     | Teto diário, unidade maior.                                                              |
| `META_ALLOWLIST_FONTE`               | `env`      | `banco` faz a allowlist vir do que `META_ESCOPO_EMAIL`o usuário somente-leitura enxerga. |
| `DB_URL` / `DB_USER` / `DB_PASSWORD` | _(nenhum)_ | Ausentes = recurso de banco desligado.                                                   |
| `DB_FERRAMENTAS_MCP`                 | `false`    | Registra as cinco ferramentas `db_*`.                                                    |

## Interface local

```bash
npm run ui         # constrói tudo e sobe em http://127.0.0.1:8787
```

Ou, do Claude Code: **`/painel`** — ou `/painel email@do-usuario` para abrir já escopado.

O **painel** lê do banco: uma consulta traz o histórico de todas as contas do escopo. Um gestor com
67 contas carrega em menos de 2 s com **zero** chamadas à Meta. O que só a Graph sabe — teto de
gastos, status da conta, moeda, fuso, campanhas ativas — chega ao expandir uma linha, uma conta por
vez. Antes eram quatro chamadas por conta na carga da tela, o que a 67 contas seriam 268 chamadas
contra um limite de ~15/min.

O **chat** responde interpretando os números, com as duas famílias de ferramenta. A conversa é
gravada em `.conversas/` (fora do git): sobrevive a trocar de aba, a F5, a fechar o navegador e a
reiniciar o servidor. Uma resposta cortada no meio volta marcada como interrompida — e nunca é
retomada sozinha, o que recobraria tokens e poderia reexecutar consultas.

### Escopo por usuário

Ativar um e-mail resolve `users → tb_usuarios_clientes → tb_clientes → tb_meta_ads` e constrói uma
`Allowlist` nova com essas contas. `ROLE_ADMIN`o usuário somente-leitura enxerga todos os clientes ativos, porque a
plataforma não enumera vínculo de administrador. Contas sem cliente vinculado **não entram no escopo
de ninguém** — conta sem dono não é de todo mundo —, mas a contagem aparece no aviso.

Ativar é ação de operador, pelo controle da tela ou por `?ativar=`. **Não** é um padrão que o sistema
reconheça dentro de uma mensagem: o agente lê texto escrito por cliente, e uma campanha chamada
`[ativar:alguem@1nort...]` viraria escalonamento de escopo por um campo que o cliente controla. O
atalho de digitação existe, mas é tratado só no navegador e removido antes do envio.

### De onde vem cada número

| Dado                                             | Fonte                                   |
| ------------------------------------------------ | --------------------------------------- |
| gasto, impressões, cliques, leads, série diária  | **banco** (`tb_meta_anuncios_metricas`) |
| cliente, segmento, saúde da coleta               | **banco**                               |
| teto de gastos, status da conta, moeda, **fuso** | **Meta**, sob demanda                   |
| campanhas ativas, dados de hoje                  | **Meta**, sob demanda                   |

A escolha da tabela foi medida, não suposta (`npm run diag:comparar`). Em julho/2026, contra a
Graph: `tb_dados_trafego` perdia 67 leads de mensagem numa conta, 54 de formulário noutra, e não
tinha linha nenhuma para uma terceira que gastou R$ 4.850 no mês. `tb_meta_anuncios_metricas` bateu
nas três, dentro de 0,2%, e ainda preserva `NULL` para "não medido" — coisa que o agregado legado
não faz (13.264 linhas, 8.875 zeros onde deveria haver nulo).

Período pedido além da cobertura é **recortado e avisado**, nunca completado em silêncio: somar um
dia do banco com um dia da Graph produz um total que não reconcilia com fonte nenhuma.

## Desenvolvimento

```bash
npm test           # 325 testes
npm run typecheck
npm run fmt
npm run diag:banco     # sonda o banco: cobertura, semântica de nulo, escopo de um e-mail
npm run diag:comparar -- act_123 2026-07-01 2026-07-31   # banco × Graph, mesma conta e período
```

Testes injetam `fetch` em vez de mockar o cliente Graph, então exercitam o que de fato quebra:
montagem de URL, a string `fields`, encoding do `time_range`, cursor de paginação e parsing de
número-string. `test/invariantes.test.ts` varre todas as URLs emitidas atrás do token, confere que
ele está no `Authorization` de toda chamada, e falha se alguma coisa escrever em stdout.

## Pendências conhecidas

1. **Conferir os números contra o Gerenciador de Anúncios antes de confiar neles.** A Meta interpreta
   datas no fuso da **conta**, e a janela de atribuição padrão molda a contagem de leads. Toda
   resposta de `meta_get_insights` traz `account_timezone` e o período resolvido justamente para
   permitir essa conferência. Pode ser necessário fixar `action_attribution_windows` explicitamente.
2. **Criar uma campanha sacrificial antes do primeiro teste de escrita real** — campanha nova,
   pausada, orçamento mínimo, sem anúncios. Senão a primeira escrita cai num cliente pagante.
3. **Ligar _Require App Secret_** nas configurações do app Meta. O `appsecret_proof` já é enviado em
   toda chamada, mas o benefício (token roubado deixa de servir sozinho) só vale depois de ativar a
   exigência do lado da Meta.
4. **Token com escopo menor.** A allowlist é controle de aplicação sobre uma credencial sem limite
   técnico. A correção estrutural é um segundo usuário de sistema do BM com acesso só às contas
   permitidas.
5. **`v21.0` expira em 2027-01-21.** Agendar o bump, de preferência junto com o Citrino, para o
   conhecimento compartilhado de erros e action types continuar valendo.
6. **Rate limit (~15 req/min por conta).** Há teto de páginas e de linhas, e o resultado sempre
   informa `truncated`. Falta decidir se vale acrescentar pausa entre páginas.
7. **`node_modules/` e `dist/` dentro do OneDrive** sofrem varredura de sincronização contínua.
   Recomendo excluir os dois do sync, ou mover o projeto para fora do OneDrive.
8. **O agregado `tb_dados_trafego` está errado onde `tb_meta_anuncios_metricas` está certo.** Vale
   investigar o coletor ou aposentar a tabela — hoje ela só alimenta `vw_desempenho_conta_diario`,
   que também expõe um `total_leads` somado e deveria sair de cena junto. Ver
   [docs/proposta-tabelas.sql](docs/proposta-tabelas.sql).
9. **`.conversas/` dentro do OneDrive** significa transcript com dado de cliente (gasto, nome de
   campanha, id de conta) sincronizando para uma nuvem pessoal. Mesmo problema do item 7, com
   conteúdo mais sensível.
10. **Postgres de produção sem TLS na internet pública.** A senha e todas as linhas viajam em texto
    claro. Não é bug deste projeto, mas vale levar a quem cuida do `db02`.

Maintenance

ActivityMaintained
ResponsivenessSyncing