Skip to main content
Glama
README.md
# radar-cfm-mcp

Servidor MCP que consulta e monitora resoluções do CFM (Conselho Federal de Medicina)
sobre inteligência artificial, telemedicina e prontuário eletrônico. Mantém uma cópia
local pesquisável e sempre devolve o link do documento oficial.

> ### ⚠️ Não é fonte oficial
>
> - **Não substitui a consulta ao portal do CFM.** Este projeto lê o que o portal publica
>   e guarda uma cópia local, que pode estar defasada.
> - **Um trecho extraído não é "a posição do CFM".** Toda resposta traz a ementa e a URL
>   do PDF oficial justamente para você conferir antes de citar.
> - **A extração de PDF pode falhar ou truncar.** Quando isso acontece o registro é
>   marcado com `metadados_incompletos`, mas leia sempre o documento original.
> - **Sem validação jurídica.** Isto é uma ferramenta de busca, não uma interpretação
>   normativa.

## Connector hospedado

Há uma instância pública deste servidor, mantida pelo autor, para quem quer usar sem
instalar nada:

```
https://debian-f.tailf42a96.ts.net/cfm/mcp
```

- **No Claude (web ou desktop):** Configurações, Conectores, adicionar conector
  personalizado, e colar a URL acima.
- **No Claude Code:** `claude mcp add --transport http radar-cfm https://debian-f.tailf42a96.ts.net/cfm/mcp`

Antes de usar, saiba o que ela é:

- **Os dados são públicos, do CFM.** A base é uma cópia do que o portal de normas
  publica, atualizada uma vez por dia de madrugada. O aviso acima vale inteiro: não é
  fonte oficial.
- **Sem garantia de disponibilidade.** Roda numa máquina pessoal exposta pelo Tailscale
  Funnel. Pode ficar fora do ar, mudar de endereço ou ser desligada sem aviso. Para uso
  de que você dependa, rode a sua (instruções abaixo).
- **Sem autenticação, com limites.** Qualquer pessoa pode chamar. Por isso há teto de
  requisições (600 por minuto por origem e 1.200 por minuto no total) e teto nos
  parâmetros (`limite` até 100, `dias` até 3.650). Acima disso a chamada é recusada.
- **O que você consulta passa por essa máquina.** Veja [Privacidade](#privacidade).

## Requisitos

| O quê | Versão | Para quê |
| --- | --- | --- |
| Python | 3.12+ | runtime |
| [uv](https://docs.astral.sh/uv/) | recente | dependências e venv |
| Espaço em disco | ~200 MB | base DuckDB + PDFs cacheados |

Este projeto **não usa LLM**: a busca é full-text sobre a base local.

## Instalação

```bash
git clone https://github.com/fabianofilho/radar-cfm-mcp.git
cd radar-cfm-mcp
uv sync
cp .env.example .env
```

## Configuração

| Variável | Padrão | Observação |
| --- | --- | --- |
| `CRAWLER_DELAY_SEGUNDOS` | `2` | intervalo entre requisições ao portal |
| `PALAVRAS_CHAVE` | inteligência artificial, telemedicina, prontuário eletrônico, algoritmo | separadas por vírgula; decidem quais PDFs o sync padrão baixa (pela ementa) e o filtro de `monitorar_novas_resolucoes` (ementa e texto) |
| `DUCKDB_PATH` | `./data/cfm.duckdb` | base local |

```bash
uv run cfm-cli sync --max-paginas 3    # teste rápido
uv run cfm-cli sync                    # varredura completa (~8 min com delay de 2s)
uv run cfm-cli consultar telemedicina
uv run cfm-cli novas --dias 90
uv run cfm-cli reextrair-datas         # refaz as datas a partir do texto já gravado
uv run cfm-cli schema
```

### Ligando ao Claude Code

```bash
claude mcp add radar-cfm --scope user \
  -e DUCKDB_PATH=/caminho/para/radar-cfm-mcp/data/cfm.duckdb \
  -- uv --directory /caminho/para/radar-cfm-mcp run radar-cfm-mcp
```

## Uso

### `consultar_resolucao_cfm(tema: str, apenas_vigentes=True, limite=10)`

`limite` vai de 1 a 100. Um tema que seja o número de uma resolução (`2314/2022`,
`2.314`) busca aquela norma diretamente, inclusive revogada.

```json
{
  "tema": "inteligência artificial",
  "total": 1,
  "retornados": 1,
  "truncado": false,
  "resultados": [
    {
      "identificador": "2454/2026",
      "ementa": "Normatiza o uso da inteligência artificial na medicina.",
      "vigente": true,
      "revogada_por": null,
      "trecho_relevante": "...Normatiza o uso da inteligência artificial na medicina. O CONSELHO FEDERAL DE MEDICINA...",
      "url_origem": "https://sistemas.cfm.org.br/normas/visualizar/resolucoes/BR/2026/2454",
      "texto_completo_disponivel": true
    }
  ]
}
```

Ordena por relevância (BM25 do FTS do DuckDB) e depois por data. Revogadas vêm com
`vigente: false` e o número da que substituiu. `total` é quantas casam na base
inteira; `truncado: true` quer dizer que há mais do que veio.

### `monitorar_novas_resolucoes(dias=30, filtrar_tema=True, limite=50)`

O que foi publicado nos últimos `dias` (1 a 3.650), mais recentes primeiro, até
`limite` (1 a 100). Com `filtrar_tema`, só as que citam alguma das `PALAVRAS_CHAVE`
na ementa ou no texto; o filtro é aplicado antes do limite. A resposta traz `total`,
`retornados`, `truncado` e `sem_data_publicacao`: a janela usa a data extraída do
texto, e as resoluções sem data ficam de fora, então total zero com muitas sem data
quer dizer "não sei", não "nada foi publicado".

## Como a fonte funciona

O portal de busca de normas renderiza os resultados no navegador a partir de uma variável
**`resultadoBuscaJson` embutida no HTML**. O crawler lê esse JSON em vez de raspar tabela:
é mais estável, e já vem com o campo de revogação.

Confirmado em 20/09/2026: **2.457 resoluções**, 10 por página, 246 páginas.

Dois detalhes que moldaram o desenho:

- **A busca textual por URL é ignorada** pelo portal (o total não muda com `busca=`), então
  o filtro por palavra-chave é aplicado localmente sobre a ementa.
- **A página de detalhe é um visualizador PDF.js**, não texto. O PDF real segue o padrão
  `sistemas.cfm.org.br/normas/arquivos/resolucoes/BR/{ano}/{numero}_{ano}.pdf`.

## Respeito ao portal

É o site de um conselho profissional, não uma API feita para volume:

- intervalo configurável entre requisições (padrão 2s);
- o mesmo PDF nunca é baixado duas vezes (cache por hash da URL);
- **Por padrão, o PDF só é baixado quando a ementa casa com alguma palavra-chave.** A
  instância hospedada baixou as 2.457 uma vez, e a coleta diária dela só baixa o que é
  novo (ver "Texto integral" abaixo).

## Modo connector (servidor HTTP)

Por padrão o servidor fala **stdio**: o cliente sobe o processo na máquina de quem usa.
Com `TRANSPORTE=streamable-http`, ele vira um servidor alcançável pela rede, que é o que
o Claude aceita como custom connector.

```bash
TRANSPORTE=streamable-http HTTP_HOST=0.0.0.0 HTTP_PORTA=8000 uv run radar-cfm-mcp
```

| Variável | Padrão | Observação |
| --- | --- | --- |
| `TRANSPORTE` | `stdio` | `streamable-http` liga o modo connector |
| `HTTP_HOST` / `HTTP_PORTA` / `HTTP_PATH` | `127.0.0.1` / `8000` / `/mcp` | |
| `HTTP_STATELESS` | `true` | cada requisição independente; escala melhor |
| `HTTP_LIMITE_GLOBAL_POR_MINUTO` | `1200` | o teto que protege a máquina |
| `HTTP_LIMITE_POR_MINUTO` | `600` | por origem, contra chamada direta |

**Por que dois limites.** Quando o Claude chama um connector remoto, as requisições chegam
dos **IPs da Anthropic**, não do usuário final. Limitar só por IP colocaria todos os
usuários no mesmo balde: ou derruba todo mundo junto, ou não protege nada. O teto global é
o que vale para esse tráfego; o por origem serve contra quem chama o servidor direto.

### Atrás de um proxy ou túnel, declare o nome público

O SDK do MCP valida o cabeçalho `Host` e responde **421 Invalid Host** ao que não
reconhece. É proteção contra DNS rebinding, um ataque em que um site qualquer faz o
navegador da vítima conversar com um servidor que só deveria ser local.

Quando o servidor fica atrás de um túnel, o `Host` que chega é o nome público, não
`127.0.0.1`, e toda requisição legítima leva 421. A saída certa é declarar o nome, não
desligar a checagem:

```bash
HTTP_HOSTS_PUBLICOS=mcp.exemplo.ts.net
```

Aceita vários separados por vírgula. O loopback continua valendo junto, porque é assim
que se testa o servidor de dentro da máquina.

### Texto integral: baixar o PDF de todas

Por padrão o sync baixa o PDF só das resoluções cuja ementa toca em IA ou
telemedicina, que é o escopo do projeto. São 5 das 2.457. Para as outras, a busca por
tema compara apenas a ementa, e `trecho_relevante` vem nulo: não porque a norma não
trate do assunto, mas porque o texto nunca foi lido. A resposta diz isso, em
`texto_completo_disponivel` e no aviso.

Para servir a base a mais gente, vale baixar tudo uma vez:

```bash
uv run cfm-cli sync --texto-integral --max-pdfs 0 --publicar
```

Com o intervalo padrão de 2s entre requisições, a primeira execução leva perto de uma
hora e meia e ocupa cerca de 250 MB em `data/pdfs`. O cache em disco evita repetir, e
resolução que já tem texto na base nem volta ao parser: as execuções seguintes só
baixam o que é novo.

A coleta diária de `deploy/radar-cfm-sync.service` roda com `--texto-integral
--max-pdfs 25`. Sem isso, uma resolução nova fora de IA e telemedicina entraria sem
texto e, portanto, sem data de publicação, e nunca apareceria no monitoramento.

**A coleta seguinte não apaga o texto.** Quem roda o sync diário sem `--texto-integral`
não traz PDF nenhum, e um upsert comum sobrescreveria as extrações com nulo. A coluna
só é substituída quando o novo valor tem conteúdo, porque texto ausente na coleta
significa "não busquei desta vez", nunca "a norma ficou sem texto".

### Rodar como serviço

`deploy/` tem as units de usuário do systemd que rodam a instância hospedada. Elas
assumem o repositório em `~/radar-cfm-mcp` e o `uv` em `~/.local/bin/uv`; ajuste os
caminhos se o seu for outro.

| Unit | O que faz |
| --- | --- |
| `radar-cfm-connector.service` | o servidor HTTP (modo connector), só leitura |
| `radar-cfm-sync.service` | uma coleta com `--publicar --texto-integral --max-pdfs 25` |
| `radar-cfm-sync.timer` | dispara a coleta todo dia às 02:00, com até 30 min de espalhamento |

```bash
cp deploy/radar-cfm-connector.service deploy/radar-cfm-sync.service \
   deploy/radar-cfm-sync.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now radar-cfm-connector.service radar-cfm-sync.timer
```

A unit do connector vem com `HTTP_HOSTS_PUBLICOS=` vazio. Preencha com o nome público
do túnel ou proxy antes de habilitar, senão tudo que chega de fora recebe 421. Para
atualizar só a coleta numa máquina que já roda o connector, copie apenas
`radar-cfm-sync.service` e `radar-cfm-sync.timer` e rode `systemctl --user
daemon-reload`; assim o nome público já configurado no connector não se perde.

O timer do systemd é o único agendador: o projeto não tem agendador interno. Para
rodar uma coleta fora do horário, `systemctl --user start radar-cfm-sync.service`, e o
resultado sai em `journalctl --user -u radar-cfm-sync`.

Ela **escuta só em 127.0.0.1**. Expor para fora é uma camada à parte, um proxy reverso
com TLS ou um túnel, que aponta para essa porta. Manter assim deixa a decisão de expor
num lugar só, em vez de espalhada em variável de ambiente.

O serviço só lê. Quem escreve é a coleta, que roda separada e troca o arquivo por rename.

Se o processo morrer, o systemd sobe de novo em 5 segundos (`Restart=always`).

### A base não vai junto, e o sync roda fora

O DuckDB recusa abrir para escrita enquanto houver um leitor, e no modo connector o
servidor abre a base a cada requisição. Escrever direto no arquivo servido falharia sempre
que a coleta caísse em cima de uma consulta.

Por isso o sync usa `--publicar`: constrói a base ao lado e troca por `os.replace`, que é
atômico no POSIX. Quem já abriu continua no arquivo antigo até fechar (o tempo de uma
requisição); quem abrir depois pega o novo.

```bash
uv run cfm-cli sync --publicar          # constrói ao lado e troca no fim
uv run cfm-cli sync --publicar --forcar # aceita base menor que a servida
```

**A base ao lado começa como cópia da servida, não vazia.** Uma varredura que pare no meio
(rede ruim, portal fora) produziria uma base com só uma parte das resoluções, e publicá-la
tiraria as outras do ar. Com a cópia, a coleta faz upsert por cima do que já existe: o pior
caso é uma base desatualizada, nunca uma base menor. A cópia é feita pelo próprio DuckDB
(`COPY FROM DATABASE`), porque um `cp` pegaria o arquivo sem o WAL pendente.

**A publicação é recusada quando a base nova encolhe mais de 10%.** Com a cópia acima, uma
varredura interrompida já não produz base pequena: ela só deixa de atualizar. A checagem
fica como rede de segurança para o que a cópia não cobre, como um clone que falhou pela
metade. A versão trocada fica como `.anterior`, e
`store.troca.reverter()` volta atrás.

### Hospedar reduz a carga no CFM

Hoje, cada pessoa que clona o repositório roda o próprio crawler nas 246 páginas. Com um
connector, uma instância varre e todo mundo consulta a mesma base. Para um portal de
conselho profissional, o connector é a opção mais respeitosa.

## Limitações conhecidas

**O corpus relevante é pequeno, e isso é do CFM, não do projeto.** Das 2.457 resoluções,
apenas **5 ementas** mencionam os temas padrão: a 2.454/2026 (IA), a 2.314/2022, as
2.227/2018 e 2.228/2019 (revogadas) e a 1.643/2002. Se você espera dezenas de resultados,
o problema é a expectativa.

**O filtro age sobre a ementa, não sobre o texto completo.** Uma resolução que trate de IA
no corpo sem dizer isso na ementa não terá o PDF baixado. Ajuste `PALAVRAS_CHAVE` se o seu
tema for outro.

**A vigência vem do portal, não de análise jurídica.** O campo reflete o que o CFM marca
como revogado. Revogação parcial ou alteração por outra resolução não aparece como tal.

**O parser depende do formato da página.** Se o portal mudar, o crawler falha com uma
mensagem explícita (`resultadoBuscaJson não encontrado`) em vez de devolver vazio em
silêncio, mas vai falhar.

**Nem toda resolução tem data de publicação.** A data sai do cabeçalho do texto, e o
campo de data do portal não serve (é a data de carga no sistema deles). Com o extrator
desta versão, medido em 24/09/2026 sobre uma cópia da base, 343 das 2.457 continuam sem
data, quase todas antigas cujo PDF traz o cabeçalho sem a data ("Publicada no D.O. Seção
I, Parte II de", e mais nada). Essas não entram em `monitorar_novas_resolucoes`, que
informa quantas são em `sem_data_publicacao`.

**O portal erra o ano em `revogada_por`.** A Resolução 1.643/2002 vem como revogada pela
"2314/2024", e a 2.314 é de 2022. O valor fica como o CFM publica, mas a resposta marca
`revogada_por_confere: false` e sugere a provável em `revogada_por_provavel`, que é
inferência nossa.

**A busca cai para `LIKE` sem o FTS.** A extensão full-text do DuckDB é baixada na primeira
execução. Sem rede, a busca continua respondendo, mais lenta e sem ranking.

## Privacidade

**Rodando localmente (stdio):**

- **Sai da máquina:** requisições ao `portal.cfm.org.br` e ao `sistemas.cfm.org.br`, para
  a busca e os PDFs públicos, só durante o sync.
- **Não sai:** os temas que você consulta. A busca roda contra a base local.

**Usando o connector hospedado:**

- **Sai da sua máquina:** os parâmetros de cada chamada (tema, dias, limite) vão para o
  servidor do autor, passando pelo Tailscale Funnel.
- **O que o servidor guarda:** o código deste projeto não registra os temas consultados.
  O único registro por requisição é o IP de origem quando uma chamada é recusada por
  limite de taxa.

Nos dois modos: sem LLM, sem telemetria, sem analytics.

## Contribuindo

Veja [CONTRIBUTING.md](CONTRIBUTING.md). Não rode o crawler em loop nem reduza o delay.

## Licença e atribuição

[Apache License 2.0](LICENSE): escolhida por o projeto tocar em regulação de conduta
médica.

Construído no contexto do [IA.med](https://iamed.cc).

TDQS

A4.6/5.0

Scored across 2 tools

Disambiguation5/5

The two tools have clearly distinct purposes: one searches resolutions by topic, the other monitors recently published resolutions. There is no overlap or ambiguity between them.

Naming Consistency5/5

Both tool names follow the same verb_noun pattern in Portuguese: consultar_resolucao_cfm and monitorar_novas_resolucoes. The naming is consistent and predictable.

Tool Count3/5

With only 2 tools, the server feels thin for a domain that could reasonably include fetching a specific resolution by number or listing by year. However, the narrow 'radar' purpose partially justifies the small count, making it borderline rather than extreme.

Completeness4/5

The tool surface covers the two core needs: finding resolutions by topic and tracking new publications. Obvious gaps like fetching a resolution by its number or browsing all resolutions are missing, but agents can work around them via the search tool, so the gaps are minor.

Maintenance

ActivityMaintained
ResponsivenessNo issues