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`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing