Skip to main content
Glama
paralelum

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
```