Skip to main content
Glama
ipbx-mcp

ipbx-mcp

by paralelum

ipbx-mcp

Servidor MCP 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

Related MCP server: utel-mcp

Instalação

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:

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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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)

{
  "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:

{
  "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)

{
  "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:

{
  "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)

{
  "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)

{
  "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)

{
  "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)

{
  "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)

{
  "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)

{
  "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

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:

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:

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:

docker run --rm \
  -v ipbx_data:/data \
  -v $PWD:/backup \
  alpine tar czf /backup/sqlite-bkp.tgz -C /data .

systemd

[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)

{
  "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

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    C
    maintenance
    An MCP server that wraps the UTEL IP-telephony REST API as MCP tools, enabling LLM agents to make authenticated HTTP requests to the UTEL API via a simple tool interface.
    1
    1
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    MCP server for Bvoip / 1Stream that exposes call-reporting, phone-status, and CRM-extension-mapping endpoints as MCP tools.
    -