ipbx-mcp
by paralelum
README.md
# ipbx-mcp
Servidor [MCP](https://modelcontextprotocol.io) do IPBX em TypeScript. Transporte **Streamable HTTP** em modo stateless, autenticação por **bearer estático** e/ou **OAuth 2.1 + Google Workspace**, persistência local em SQLite (clients OAuth, refresh tokens, audit log). Herdado do scaffold `base-mcp`, expõe os dados do PABX (MySQL) como tools tipadas.
URL pública em produção: `https://mcp.ipbx.vivavox.com.br`.
## Requisitos
- Node.js **>= 22** (`better-sqlite3` v12 precisa)
- Para OAuth: OAuth Client no Google Cloud Console em modo **Internal**
## Instalação
```bash
npm install
cp .env.example .env # depois preencha os valores reais
npm run build
```
## Configuração
Carregue o `.env` no processo (systemd `EnvironmentFile=`, docker `env_file:`, ou `node --env-file=.env` na hora do start).
### Obrigatórias
**Pelo menos um** dos caminhos de auth:
| Variável | Quando usar |
| ------------------- | ------------------------------------------------------------------------ |
| `MCP_AUTH_TOKEN` | Bearer estático — Claude Desktop, CLI, API, scripts, cron |
| `OAUTH_JWT_SECRET` + `OAUTH_ISSUER` | OAuth — clientes via claude.ai (web/mobile) |
### OAuth (opcional, mas necessário pra claude.ai)
| Variável | Descrição |
| ----------------------- | ------------------------------------------------------------------------- |
| `OAUTH_ISSUER` | URL canônica do servidor (ex: `https://mcp.ipbx.vivavox.com.br`) |
| `OAUTH_JWT_SECRET` | Chave HS256 dos JWTs (32 bytes hex) |
| `GOOGLE_CLIENT_ID` | Do OAuth Client no Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | Do OAuth Client no Google Cloud Console |
| `ALLOWED_GOOGLE_HD` | Domínio Workspace permitido (default: `vivavox.com.br`) |
Quando todas estão presentes, as rotas `/authorize`, `/oauth/google/callback`, `/token` e `/register` (DCR) são montadas. Sem elas, só o bearer estático funciona.
### Outras
| Variável | Default | Descrição |
| -------------------- | -------------- | ----------------------------------------------- |
| `PORT` | `3000` | Porta HTTP |
| `HOST` | `0.0.0.0` | Interface (use `127.0.0.1` em dev local) |
| `MCP_ALLOWED_HOSTS` | — | Lista CSV de hosts aceitos no header `Host` |
| `SQLITE_PATH` | `./data/app.db`| Caminho do arquivo SQLite |
| `IPBX_RECORD_BASE_URL` | — | Base da API do IPBX que serve as gravações (ex: `https://ipbx.vivavox.com.br/api`). Sem ela, `ipbx_recording_get` falha |
### MySQL (fonte de dados do IPBX)
| Variável | Default | Descrição |
| ------------------- | ------- | -------------------------------------------------- |
| `MYSQL_HOST` | — | Host do MySQL |
| `MYSQL_PORT` | `3306` | |
| `MYSQL_USER` | — | Use um usuário dedicado com `GRANT SELECT` apenas |
| `MYSQL_PASSWORD` | — | |
| `MYSQL_DATABASE` | — | |
| `MYSQL_POOL_LIMIT` | `5` | Tamanho do pool (`mysql2`) |
| `MYSQL_SSL` | vazio | Qualquer valor liga TLS com verificação de cert |
| `IPBX_ID` | — | Tenant que esta instância atende (ver abaixo) |
O banco é multi-tenant — uma instância Asterisk por cliente, tabela `ipbx` — mas **cada instância do MCP atende um tenant só**. Todas as queries filtram por `IPBX_ID`, e nenhuma tool aceita esse id como parâmetro: assim o isolamento entre clientes não depende do que o modelo passa na chamada. Um container e um subdomínio por tenant.
Gere tokens aleatórios com:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
## Endpoints
| Método | Path | Auth | Descrição |
| ------ | ------------------------------------------------- | -------- | ------------------------------------ |
| POST | `/mcp` | bearer | JSON-RPC do MCP via Streamable HTTP |
| GET | `/mcp` | bearer | `405` |
| DELETE | `/mcp` | bearer | `405` |
| GET | `/health` | público | `{"status":"ok"}` |
| GET | `/.well-known/oauth-authorization-server` | público | RFC 8414 metadata |
| GET | `/.well-known/oauth-protected-resource` | público | RFC 9728 metadata |
| POST | `/register` | público | Dynamic Client Registration (RFC 7591) |
| GET | `/authorize` | público | Redireciona pro Google |
| GET | `/oauth/google/callback` | público | Recebe o redirect do Google |
| POST | `/token` | público | `authorization_code` / `refresh_token` |
`401` no `/mcp` inclui `WWW-Authenticate: Bearer realm=..., resource_metadata=...` — sem isso claude.ai não descobre o AS no primeiro contato.
## Tools disponíveis
Nome das tools segue `ipbx_<model>_<action>`, com `<action>` no vocabulário `list` / `get` / `search` / `count`.
### `ipbx_instance_get`
Dados de cadastro da instância IPBX que este servidor atende — nome, IP e portas SIP/AMI.
**Parâmetros:** nenhum. A instância é fixa, definida por `IPBX_ID` no ambiente.
**Retorno:**
```json
{
"id": 1,
"shortname": "vivavox",
"fullname": "Vivavox Telecom",
"ipaddr": "138.94.55.155",
"sipport": 5601,
"amiport": 6501,
"created": "2024-06-17T16:37:59.000Z",
"updated": "2024-06-17T16:37:59.000Z"
}
```
Devolve `isError` se o `IPBX_ID` configurado não existir na tabela `ipbx`.
### `ipbx_branch_list`
Lista os ramais da instância.
**Parâmetros:**
- `search` (string, opcional): busca parcial por número do ramal ou nome
- `limit` (number, opcional): 1–500, default `100`
**Retorno:**
```json
{
"total": 27,
"truncated": false,
"branches": [
{
"id": 2,
"exten": "23",
"name": "Ricardo Landim",
"group": "Suporte",
"record": true,
"webrtc": false,
"dtmf": "rfc4733",
"forward_busy": "035988023317",
"forward_noanswer": "035988023317",
"forward_noanswer_wait": 5
}
]
}
```
**Não retorna as credenciais SIP.** As colunas `password` (senha em claro) e `username` (identificador de autenticação, diferente do número do ramal) ficam de fora por design — juntas permitem registrar um softphone e originar chamadas na conta do cliente. A lista de colunas no `SELECT` é explícita justamente para que nenhuma delas entre por descuido.
### `ipbx_user_list`
Lista os usuários do painel da instância.
**Parâmetros:**
- `search` (string, opcional): busca parcial por nome ou email
- `limit` (number, opcional): 1–500, default `100`
**Retorno:**
```json
{
"total": 6,
"truncated": false,
"users": [
{
"id": 11,
"name": "Suporte",
"email": "suporte@vivavox.com.br",
"created": "2024-07-10T13:56:41.000Z",
"updated": "2024-07-10T13:56:41.000Z"
}
]
}
```
**Não retorna a senha de acesso.** A coluna `secret` fica de fora: é a senha de login do painel, guardada **em texto puro** no banco (sem hash). Expor isso entregaria acesso administrativo ao PABX.
### `ipbx_group_list`
Lista os grupos de ramais da instância, com quantos ramais cada um tem.
**Parâmetros:**
- `search` (string, opcional): busca parcial por nome ou descrição
- `limit` (number, opcional): 1–500, default `100`
**Retorno:**
```json
{
"total": 6,
"truncated": false,
"groups": [
{
"id": 1,
"name": "Suporte",
"description": "Grupo do suporte",
"branches": 11
}
]
}
```
A tabela `groups` não guarda credenciais — ao contrário de `branch` e `users`, aqui todas as colunas são expostas.
### `ipbx_trunk_list`
Lista os troncos da instância.
**Parâmetros:**
- `search` (string, opcional): busca parcial por nome ou host
- `limit` (number, opcional): 1–500, default `100`
**Retorno:**
```json
{
"total": 2,
"truncated": false,
"trunks": [
{
"id": 1,
"name": "Vivavox",
"host": "sip.vivavox.com.br",
"port": "5060",
"register": true,
"record": true,
"auth": "credentials"
}
]
}
```
**Não retorna as credenciais da operadora.** `username` e `password` ficam de fora — são a credencial mais valiosa do banco, já que permitem originar chamadas direto pela operadora, tarifadas na conta. No lugar delas vai `auth`, que diz apenas *como* o tronco autentica: `"credentials"` (usuário/senha) ou `"ip"` (allowlist de IP, sem senha).
### `ipbx_queue_list`
Lista as filas de atendimento, com a estratégia de distribuição e quantos membros cada uma tem.
**Parâmetros:** `search` (string, opcional), `limit` (1–500, default `100`)
```json
{
"total": 5,
"queues": [
{ "id": 1, "name": "Suporte", "strategy": "ringall", "members": 8 },
{ "id": 5, "name": "Teste", "strategy": "leastrecent", "members": 1 }
]
}
```
### `ipbx_queue_member_list`
Lista os membros das filas, na ordem de toque.
**Parâmetros:**
- `queue_id` (number, opcional): filtra uma fila; omita para trazer todas
- `limit` (number, opcional): 1–500, default `200`
**Retorno:**
```json
{
"total": 8,
"members": [
{
"queue_id": 1,
"queue": "Suporte",
"position": 1,
"type": "branch",
"exten": "29",
"name": "Mateus Damaceno",
"ref": "branch-10"
}
]
}
```
A coluna `queue_member.member` guarda uma referência no formato `<tipo>-<id>` — `branch-10` aponta para o `branch.id` 10, que é o ramal `29`. **Não é o número do ramal.** A tool resolve isso para `exten` + `name` quando o membro é um ramal. Nem todo membro é: existem entradas `redirect-N`, que voltam com `type: "redirect"` e `exten`/`name` nulos.
### `ipbx_ivr_list`
Lista as URAs, com o áudio associado e a transcrição do que é falado para quem liga.
**Parâmetros:** `search` (string, opcional — casa no nome **ou** no texto da transcrição), `limit` (1–500, default `100`)
```json
{
"total": 1,
"ivrs": [
{
"id": 5,
"name": "URA Rompimento",
"audio": "URA Rompimento",
"transcription": "Olá, se você está com falta de conexão e o LED Loss do seu modem óptico...",
"options": 1
}
]
}
```
A transcrição é o campo mais útil: permite achar uma URA pelo que ela diz, não só pelo nome.
### `ipbx_ivr_option_list`
Lista as opções das URAs — qual tecla leva a qual destino.
**Parâmetros:**
- `ivr_id` (number, opcional): filtra uma URA; omita para trazer todas
- `limit` (number, opcional): 1–500, default `200`
**Retorno:**
```json
{
"total": 7,
"options": [
{
"ivr_id": 1,
"ivr": "URA Principal - Horario comercial",
"digit": "1",
"goto": { "type": "queue", "name": "Financeiro", "exten": null, "ref": "queue-3" }
},
{
"ivr_id": 1,
"ivr": "URA Principal - Horario comercial",
"digit": "7X",
"goto": { "type": "internal", "name": null, "exten": null, "ref": "internal" }
}
]
}
```
`ivr_option.goto` é **polimórfico**: aponta para 5 tabelas diferentes (`branch`, `queue`, `ivr`, `redirect`, `app`) no formato `<tipo>-<id>`, e ainda aceita literais sem id (`internal`). A tool resolve o nome do destino em todos os casos; literais voltam com `name` nulo e o `ref` preservado.
O campo `digit` nem sempre é um dígito: `t` é timeout e padrões como `7X` casam faixas de ramal.
### `ipbx_redirect_list`
Lista os redirects — ramais curtos que encaminham para um número externo saindo por um tronco. São os mesmos `redirect-<id>` que aparecem como destino em filas, URAs e regras de roteamento.
**Parâmetros:** `search` (string, opcional — casa ramal, nome ou número), `limit` (1–500, default `100`)
```json
{
"total": 12,
"redirects": [
{
"id": 2,
"exten": "73",
"name": "Ricardo Landim",
"forward": "5535988023317",
"trunk": "Vivavox",
"ref": "redirect-2"
}
]
}
```
⚠️ **Dado pessoal.** `forward` é um número de celular pessoal em 100% das linhas — não é credencial, mas é dado pessoal sob LGPD. A tool o retorna porque é a razão de existir da tabela, mas ele **não** vai para o `audit_log`.
### `ipbx_routing_list`
Lista os planos de roteamento, com quantas regras e janelas de horário cada um tem.
**Parâmetros:** `search` (string, opcional), `limit` (1–500, default `100`)
```json
{
"total": 2,
"routings": [
{ "id": 1, "name": "Entrada - Padrão", "rules": 6, "time_windows": 3 },
{ "id": 2, "name": "Saida - Padrão", "rules": 8, "time_windows": 1 }
]
}
```
### `ipbx_routing_time_list`
Lista as janelas de horário dos planos.
**Parâmetros:** `routing_id` (number, opcional), `limit` (1–500, default `100`)
```json
{
"id": 1,
"routing": "Entrada - Padrão",
"name": "Horario comercial",
"ranges": ["08:00-18:00,mon", "08:00-18:00,tue", "08:00-12:00,sat"]
}
```
O `pattern` é armazenado no formato do Asterisk, uma faixa por linha; a tool devolve como lista.
### `ipbx_routing_rule_list`
Lista as regras de roteamento — o dialplan. Cada regra casa um padrão de número dentro de uma janela de horário, suprime dígitos, adiciona prefixo e envia ao destino.
**Parâmetros:** `routing_id` (number, opcional), `limit` (1–500, default `200`)
```json
{
"id": 4,
"routing": "Saida - Padrão",
"name": "LDN",
"time_window": "Geral",
"match": "0ZZ.",
"suppress": 1,
"prefix": "55",
"goto": { "type": "trunk", "name": "Vivavox", "exten": null, "ref": "trunk-1" }
}
```
`goto1` é polimórfico como o da URA, **mais o tipo `trunk`** (usado nas regras de saída) — seis destinos possíveis no total.
Dois detalhes do schema tratados aqui: a coluna do banco chama-se `supress` (com um "p"), exposta como `suppress`; e `goto2`/`goto3` existem mas estão vazias em todas as linhas — aparecem como `goto_extra` apenas se algum dia forem preenchidas.
### `ipbx_cdr_list`
Histórico de chamadas. Período é obrigatório e limitado a 31 dias: a `cdr` não tem índice além da PK, então todo filtro é full scan (~268k linhas hoje).
**Parâmetros:** `date_from` e `date_to` (`YYYY-MM-DD`, obrigatórios), `scope` (`call` | `leg`, default `call`), `src` e `dst` (parcial), `branch_id`, `trunk_id`, `answered` (bool), `call_id`, `limit` (1–500, default `25`)
```json
{
"call_id": "sip1-1787578699.251937",
"started": "2026-08-24 10:38:19",
"ended": "2026-08-24 10:42:06",
"direction": "inbound",
"from": { "type": "trunk", "id": 1, "name": "Vivavox" },
"caller": "35997609940",
"dialed": null,
"context": "queue-3",
"answered": true,
"talk_seconds": 265,
"ring_attempts": 6,
"targets": [{ "type": "branch", "id": 16, "exten": "35", "name": "Ester Vilela" }],
"answered_by": [{ "type": "branch", "id": 16, "exten": "35", "name": "Ester Vilela" }],
"dispositions": ["ANSWERED", "NO ANSWER"],
"has_recording": true,
"legs": 10
}
```
A `cdr` é a única tabela do PABX sem `ipbx_id`. O vínculo com o tenant é o `systemname` do Asterisk, que o ipbx-api escreve como `sip<ipbx_id>` e o Asterisk carimba no `uniqueid`/`linkedid` de cada linha — o filtro é `uniqueid LIKE 'sip<id>-%'`, com o hífen (sem ele, `sip1` casaria `sip10-` também).
Uma chamada são muitas linhas: `uniqueid` identifica o canal, `linkedid` a chamada, e cada tentativa de Dial gera uma linha — uma entrante de fila chega a 22. `scope=call` agrupa por `linkedid`; as pernas cujo destino é um canal `Local/` são o toque da fila em cada membro (viram `ring_attempts`) e as demais são conversa (somam `talk_seconds`). `scope=leg` devolve as pernas cruas — use com `call_id` pra depurar uma chamada.
`has_recording` exige `ANSWERED` além de `rec` preenchido — mesma regra do `recAvailable` do painel. A coluna `rec` é escrita antes do `Dial` (o dialplan arma `MixMonitor` no `prerouting`), então ela marca "gravação armada" e não "existe áudio": sozinha, daria gravação em 99,96% das chamadas.
Nenhuma coluna de canal sai crua: `channel`, `dstchannel` e `lastdata` carregam o `username` do endpoint, que é metade da credencial SIP, e `src` traz esse mesmo username em chamada interna. Tudo passa por `src/channel.ts` e sai como ramal/tronco/fila. `rec` também fica fora — vira `has_recording`.
Filtrar por `branch_id`/`trunk_id` seleciona as chamadas por semi-join, não por linha: os agregados continuam descrevendo a chamada inteira, não só as pernas daquele ramal.
### `ipbx_recording_get`
URL do áudio de **uma** chamada, a partir do `call_id` que o `ipbx_cdr_list` devolve.
**Parâmetros:** `call_id` (string, obrigatório)
```json
{
"call_id": "sip1-1787577145.251772",
"started": "2026-08-24 10:12:25",
"has_recording": true,
"url": "https://ipbx.vivavox.com.br/api/call/record/sip1-8f0e5161….wav",
"note": "URL publica e sem expiracao: o nome do arquivo e a unica credencial. …"
}
```
É tool separada em vez de campo do `ipbx_cdr_list` por um motivo: a rota `/call/record` do ipbx-api não exige autenticação e a URL não expira — o nome do arquivo (SHA1) **é** a credencial. Como campo de listagem, cada chamada do CDR despejaria 25 acessos permanentes a conversas no contexto, quase todos nunca usados, e o audit teria que gravar 25 credenciais ou não registrar nada. Uma tool por gravação dá uma linha de auditoria com a identidade de quem pediu. O `has_recording` do CDR é o sinal de descoberta; esta tool é o acesso.
Sem áudio, a resposta diz o motivo em vez de só negar — `Chamada nao atendida` (a gravação é armada antes do `Dial`) ou gravação desligada no ramal. `call_id` de outro tenant devolve `isError`: o filtro por `IPBX_ID` é aplicado na query, e o `sip<id>` da URL vem do ambiente, nunca do `call_id` recebido.
Depende de `IPBX_RECORD_BASE_URL`. Sem ela o servidor sobe normalmente e só esta tool falha, com mensagem explícita — é como desligar a tool num servidor.
Toda chamada gera uma linha em `audit_log` com a identidade do chamador: email Google se JWT, `service:static` se bearer estático. `src`/`dst` do `ipbx_cdr_list` são telefone e não vão pro audit — fica só `number_filter: true`.
## Comandos
```bash
npm run build # tsc
npm run check # tsc --noEmit (sem emitir)
npm run dev # tsc --watch
npm start # node dist/index.js
npm run inspect # MCP Inspector
```
Smoke test local:
```bash
curl -s http://localhost:3000/health
curl -s http://localhost:3000/.well-known/oauth-authorization-server
curl -s -X POST http://localhost:3000/mcp \
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
## Deploy
### Docker (recomendado)
`Dockerfile` multi-stage (`node:22-slim`), runtime como user não-root `mcp`, expõe `/data` como volume pro SQLite, healthcheck via `/health`. Em produção o deploy é automático via `.github/workflows/deploy.yml` (push de tag `vX.Y.Z` → build no GHCR → `docker run` na VPS). Manualmente:
```bash
docker image build . -t ipbx-mcp:1.0
docker container run -d --env-file .env -p 50020:3000 \
-v ipbx_data:/data --restart unless-stopped --name ipbx-mcp ipbx-mcp:1.0
docker stop ipbx-mcp && docker rm ipbx-mcp
docker logs -f ipbx-mcp
```
Backup do SQLite:
```bash
docker run --rm \
-v ipbx_data:/data \
-v $PWD:/backup \
alpine tar czf /backup/sqlite-bkp.tgz -C /data .
```
### systemd
```ini
[Unit]
Description=ipbx-mcp
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/local/ipbx-mcp
ExecStart=/usr/bin/node dist/index.js
EnvironmentFile=/var/local/ipbx-mcp/.env
User=mcp
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
`EnvironmentFile=` é o equivalente nativo do systemd para `.env`. Use um usuário dedicado (`mcp`) em vez de `root`.
## Configurando em um cliente MCP
### Claude Desktop / CLI (bearer estático)
```json
{
"mcpServers": {
"ipbx": {
"type": "http",
"url": "https://mcp.ipbx.vivavox.com.br/mcp",
"headers": {
"Authorization": "Bearer SEU_MCP_AUTH_TOKEN"
}
}
}
}
```
### claude.ai (OAuth)
Adicionar como Custom Connector usando `https://mcp.ipbx.vivavox.com.br/mcp`. O flow OAuth dispara automaticamente — claude.ai descobre o AS via `WWW-Authenticate`, registra um client via DCR, redireciona pro Google, recebe o code e troca por um access token.
## Estrutura
```
src/
index.ts # bootstrap HTTP, leitura de env, registro de rotas
server.ts # createServer() registra as tools (ipbx_*)
mysql.ts # pool mysql2 + queries do IPBX (tenant fixo)
channel.ts # nome de canal do Asterisk -> ramal/tronco/fila
sqlite.ts # better-sqlite3 + apply schemas
audit.ts # logToolCall() -> audit_log
auth/
jwt.ts # sign/verify HS256 (jose)
middleware.ts # requireAuth: JWT -> fallback bearer estático
oauth/
routes.ts # registerOAuthRoutes()
store.ts # DCR clients, codes, refresh, authorize-tx
google.ts # OAuth do Google (authorize URL + token exchange)
pkce.ts # verificação S256 em tempo constante
sql/
001_oauth_schema.sql # oauth_clients, oauth_codes, oauth_refresh_tokens, audit_log
002_oauth_authorize_tx.sql # oauth_authorize_tx (state Google <-> params)
Dockerfile
.github/workflows/deploy.yml # build GHCR + deploy SSH na VPS
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues