Skip to main content
Glama
ipbx-mcp

ipbx-mcp

by paralelum

ipbx-mcp

Сервер MCP для IPBX на TypeScript. Транспорт Streamable HTTP в stateless-режиме, аутентификация по статическому bearer и/или OAuth 2.1 + Google Workspace, локальное хранение в SQLite (OAuth-клиенты, refresh-токены, журнал аудита). Унаследован от каркаса base-mcp, предоставляет данные АТС (MySQL) как типизированные tools.

Публичный URL в продакшене: https://mcp.ipbx.vivavox.com.br.

Требования

  • Node.js >= 22 (нужен для better-sqlite3 v12)

  • Для OAuth: OAuth-клиент в Google Cloud Console в режиме Internal

Related MCP server: utel-mcp

Установка

npm install
cp .env.example .env   # depois preencha os valores reais
npm run build

Конфигурация

Загрузите .env в процесс (systemd EnvironmentFile=, docker env_file:, или node --env-file=.env при запуске).

Обязательные

Как минимум один из способов аутентификации:

Переменная

Когда использовать

MCP_AUTH_TOKEN

Статический bearer — Claude Desktop, CLI, API, скрипты, cron

OAUTH_JWT_SECRET + OAUTH_ISSUER

OAuth — клиенты через claude.ai (web/mobile)

OAuth (необязательно, но необходимо для claude.ai)

Переменная

Описание

OAUTH_ISSUER

Канонический URL сервера (например: https://mcp.ipbx.vivavox.com.br)

OAUTH_JWT_SECRET

Ключ HS256 для JWT (32 байта hex)

GOOGLE_CLIENT_ID

Из OAuth-клиента в Google Cloud Console

GOOGLE_CLIENT_SECRET

Из OAuth-клиента в Google Cloud Console

ALLOWED_GOOGLE_HD

Разрешённый домен Workspace (по умолчанию: vivavox.com.br)

Когда все они присутствуют, монтируются маршруты /authorize, /oauth/google/callback, /token и /register (DCR). Без них работает только статический bearer.

Прочие

Переменная

По умолчанию

Описание

PORT

3000

HTTP-порт

HOST

0.0.0.0

Интерфейс (используйте 127.0.0.1 в локальной разработке)

MCP_ALLOWED_HOSTS

—

Список CSV хостов, принимаемых в заголовке Host

SQLITE_PATH

./data/app.db

Путь к файлу SQLite

IPBX_RECORD_BASE_URL

—

Базовый URL API IPBX, который отдаёт записи (например: https://ipbx.vivavox.com.br/api). Без него ipbx_recording_get завершится ошибкой

MySQL (источник данных IPBX)

Переменная

По умолчанию

Описание

MYSQL_HOST

—

Хост MySQL

MYSQL_PORT

3306

MYSQL_USER

—

Используйте выделенного пользователя только с GRANT SELECT

MYSQL_PASSWORD

—

MYSQL_DATABASE

—

MYSQL_POOL_LIMIT

5

Размер пула (mysql2)

MYSQL_SSL

пусто

Любое значение включает TLS с проверкой сертификата

IPBX_ID

—

Тенант, который обслуживает этот экземпляр (см. ниже)

База данных мультитенантная — один экземпляр Asterisk на клиента, таблица ipbx — но каждый экземпляр MCP обслуживает только один тенант. Все запросы фильтруются по IPBX_ID, и ни одна tool не принимает этот id как параметр: так изоляция между клиентами не зависит от того, что модель передаёт в вызове. Один контейнер и один поддомен на тенанта.

Сгенерируйте случайные токены с помощью:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Эндпоинты

Метод

Path

Auth

Описание

POST

/mcp

bearer

JSON-RPC MCP через Streamable HTTP

GET

/mcp

bearer

405

DELETE

/mcp

bearer

405

GET

/health

публичный

{"status":"ok"}

GET

/.well-known/oauth-authorization-server

публичный

метаданные RFC 8414

GET

/.well-known/oauth-protected-resource

публичный

метаданные RFC 9728

POST

/register

публичный

Dynamic Client Registration (RFC 7591)

GET

/authorize

публичный

Перенаправляет на Google

GET

/oauth/google/callback

публичный

Принимает редирект от Google

POST

/token

публичный

authorization_code / refresh_token

401 на /mcp включает WWW-Authenticate: Bearer realm=..., resource_metadata=... — без этого claude.ai не обнаружит AS при первом контакте.

Доступные tools

Имена tools следуют схеме ipbx_<model>_<action>, где <action> из словаря list / get / search / count.

ipbx_instance_get

Данные регистрации экземпляра IPBX, который обслуживает этот сервер — имя, IP и порты SIP/AMI.

Параметры: нет. Экземпляр фиксирован, задаётся через IPBX_ID в окружении.

Возврат:

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

Возвращает isError, если настроенный IPBX_ID не существует в таблице ipbx.

ipbx_branch_list

Список внутренних номеров (ramais) экземпляра.

Параметры:

  • search (string, необязательно): частичный поиск по номеру внутреннего или имени

  • limit (number, необязательно): 1–500, по умолчанию 100

Возврат:

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

Не возвращает SIP-учётные данные. Колонки password (пароль в открытом виде) и username (идентификатор аутентификации, отличающийся от номера внутреннего) исключены намеренно — вместе они позволяют зарегистрировать софтфон и совершать звонки с аккаунта клиента. Список колонок в SELECT явный именно для того, чтобы ни одна из них не попала случайно.

ipbx_user_list

Список пользователей панели экземпляра.

Параметры:

  • search (string, необязательно): частичный поиск по имени или email

  • limit (number, необязательно): 1–500, по умолчанию 100

Возврат:

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

Не возвращает пароль доступа. Колонка secret исключена: это пароль для входа в панель, хранящийся в открытом виде в базе (без хеша). Раскрытие этого дало бы административный доступ к АТС.

ipbx_group_list

Список групп внутренних номеров экземпляра, с количеством внутренних в каждой.

Параметры:

  • search (string, необязательно): частичный поиск по имени или описанию

  • limit (number, необязательно): 1–500, по умолчанию 100

Возврат:

{
  "total": 6,
  "truncated": false,
  "groups": [
    {
      "id": 1,
      "name": "Suporte",
      "description": "Grupo do suporte",
      "branches": 11
    }
  ]
}

Таблица groups не хранит учётные данные — в отличие от branch и users, здесь раскрываются все колонки.

ipbx_trunk_list

Список транков экземпляра.

Параметры:

  • search (string, необязательно): частичный поиск по имени или хосту

  • limit (number, необязательно): 1–500, по умолчанию 100

Возврат:

{
  "total": 2,
  "truncated": false,
  "trunks": [
    {
      "id": 1,
      "name": "Vivavox",
      "host": "sip.vivavox.com.br",
      "port": "5060",
      "register": true,
      "record": true,
      "auth": "credentials"
    }
  ]
}

Не возвращает учётные данные оператора. username и password исключены — это самые ценные учётные данные в базе, так как позволяют совершать звонки напрямую через оператора, тарифицируемые на счёт. Вместо них идёт auth, который говорит только как транк аутентифицируется: "credentials" (пользователь/пароль) или "ip" (белый список IP, без пароля).

ipbx_queue_list

Список очередей обслуживания, со стратегией распределения и количеством участников в каждой.

Параметры: search (string, необязательно), limit (1–500, по умолчанию 100)

{
  "total": 5,
  "queues": [
    { "id": 1, "name": "Suporte", "strategy": "ringall", "members": 8 },
    { "id": 5, "name": "Teste", "strategy": "leastrecent", "members": 1 }
  ]
}

ipbx_queue_member_list

Список участников очередей, в порядке обзвона.

Параметры:

  • queue_id (number, необязательно): фильтрует одну очередь; опустите, чтобы получить все

  • limit (number, необязательно): 1–500, по умолчанию 200

Возврат:

{
  "total": 8,
  "members": [
    {
      "queue_id": 1,
      "queue": "Suporte",
      "position": 1,
      "type": "branch",
      "exten": "29",
      "name": "Mateus Damaceno",
      "ref": "branch-10"
    }
  ]
}

Колонка queue_member.member хранит ссылку в формате <тип>-<id> — branch-10 указывает на branch.id 10, который является внутренним номером 29. Это не номер внутреннего. Tool разрешает это в exten + name, когда участник — внутренний номер. Не каждый участник такой: существуют записи redirect-N, которые возвращаются с type: "redirect" и exten/name равными null.

ipbx_ivr_list

Список IVR (URA), с привязанным аудио и транскрипцией того, что говорится звонящему.

Параметры: search (string, необязательно — совпадает по имени или тексту транскрипции), limit (1–500, по умолчанию 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
    }
  ]
}

Транскрипция — самое полезное поле: позволяет найти IVR по тому, что она говорит, а не только по имени.

ipbx_ivr_option_list

Список опций IVR — какая клавиша ведёт к какому назначению.

Параметры:

  • ivr_id (number, необязательно): фильтрует одну IVR; опустите, чтобы получить все

  • limit (number, необязательно): 1–500, по умолчанию 200

Возврат:

{
  "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 полиморфен: указывает на 5 разных таблиц (branch, queue, ivr, redirect, app) в формате <тип>-<id>, а также принимает литералы без id (internal). Tool разрешает имя назначения во всех случаях; литералы возвращаются с name равным null и сохранённым ref.

Поле digit не всегда является цифрой: t — это таймаут, а шаблоны вроде 7X соответствуют диапазонам внутренних номеров.

ipbx_redirect_list

Список редиректов — коротких внутренних номеров, которые перенаправляют на внешний номер через транк. Это те же redirect-<id>, которые появляются как назначения в очередях, IVR и правилах маршрутизации.

Параметры: search (string, необязательно — совпадает по внутреннему номеру, имени или номеру), limit (1–500, по умолчанию 100)

{
  "total": 12,
  "redirects": [
    {
      "id": 2,
      "exten": "73",
      "name": "Ricardo Landim",
      "forward": "5535988023317",
      "trunk": "Vivavox",
      "ref": "redirect-2"
    }
  ]
}

⚠️ Персональные данные. forward — это личный номер мобильного телефона в 100% строк — это не учётные данные, но персональные данные по LGPD. Tool возвращает его, потому что это причина существования таблицы, но он не попадает в audit_log.

ipbx_routing_list

Список планов маршрутизации, с количеством правил и временных окон в каждом.

Параметры: search (string, необязательно), limit (1–500, по умолчанию 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

Список временных окон планов.

Параметры: routing_id (number, необязательно), limit (1–500, по умолчанию 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"]
}

pattern хранится в формате Asterisk, один диапазон на строку; tool возвращает его как список.

ipbx_routing_rule_list

Список правил маршрутизации — диалплан. Каждое правило сопоставляет шаблон номера внутри временного окна, удаляет цифры, добавляет префикс и отправляет на назначение.

Параметры: routing_id (number, необязательно), limit (1–500, по умолчанию 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 полиморфен, как у IVR, плюс тип trunk (используется в правилах исходящих) — всего шесть возможных назначений.

Две детали схемы, обработанные здесь: колонка в базе называется supress (с одним "p"), раскрывается как suppress; а goto2/goto3 существуют, но пусты во всех строках — появляются как goto_extra, только если когда-нибудь будут заполнены.

ipbx_cdr_list

История звонков. Период обязателен и ограничен 31 днём: в cdr нет индекса, кроме PK, поэтому любой фильтр — это полное сканирование (~268k строк на сегодня).

Параметры: date_from и date_to (YYYY-MM-DD, обязательные), scope (call | leg, по умолчанию call), src и dst (частичные), branch_id, trunk_id, answered (bool), call_id, limit (1–500, по умолчанию 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
}

cdr — единственная таблица АТС без ipbx_id. Связь с тенантом — это systemname из Asterisk, который ipbx-api записывает как sip<ipbx_id>, а Asterisk проставляет в uniqueid/linkedid каждой строки — фильтр uniqueid LIKE 'sip<id>-%' с дефисом (без него sip1 совпал бы и с sip10- тоже).

Звонок — это много строк: uniqueid идентифицирует канал, linkedid — вызов, и каждая попытка Dial создаёт строку — входящий из очереди доходит до 22. scope=call группирует по linkedid; ветви, чей пункт назначения — канал Local/, это звонок очереди каждому члену (становятся ring_attempts), а остальные — разговор (суммируются в talk_seconds). scope=leg возвращает сырые ветви — используйте с call_id для отладки вызова.

has_recording требует ANSWERED в дополнение к заполненному rec — то же правило, что и recAvailable в панели. Колонка rec записывается до Dial (диалплан настраивает MixMonitor в prerouting), поэтому она отмечает «запись подготовлена», а не «есть аудио»: сама по себе она дала бы запись в 99,96% вызовов.

Ни одна колонка канала не выходит сырой: channel, dstchannel и lastdata несут username конечной точки, который является половиной SIP-учётных данных, а src приносит тот же username во внутреннем вызове. Всё проходит через src/channel.ts и выходит как внутренний номер/транк/очередь. rec тоже остаётся вне — превращается в has_recording.

Фильтрация по branch_id/trunk_id выбирает вызовы через полу-соединение, а не по строке: агрегаты по-прежнему описывают весь вызов, а не только ветви этого филиала.

ipbx_recording_get

URL аудио одного вызова по call_id, который возвращает ipbx_cdr_list.

Параметры: call_id (строка, обязательный)

{
  "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. …"
}

Это отдельный инструмент, а не поле ipbx_cdr_list, по причине: маршрут /call/record в ipbx-api не требует аутентификации, и URL не истекает — имя файла (SHA1) и есть учётные данные. Как поле списка, каждый вызов из CDR вываливал бы 25 постоянных доступов к разговорам в контекст, почти все никогда не используемые, а аудит должен был бы записывать 25 учётных данных или не регистрировать ничего. Один инструмент на запись даёт строку аудита с личностью запросившего. has_recording из CDR — сигнал обнаружения; этот инструмент — доступ.

Без аудио ответ объясняет причину, а не просто отказывает — Chamada nao atendida (запись подготавливается до Dial) или запись отключена на внутреннем номере. call_id из другого тенанта возвращает isError: фильтр по IPBX_ID применяется в запросе, а sip<id> из URL берётся из окружения, никогда из полученного call_id.

Зависит от IPBX_RECORD_BASE_URL. Без него сервер запускается нормально, и только этот инструмент падает с явным сообщением — это как отключить инструмент на сервере.

Каждый вызов создаёт строку в audit_log с личностью вызывающего: email Google, если JWT, service:static, если статический bearer. src/dst из ipbx_cdr_list — это телефон и не идут в аудит — остаётся только number_filter: true.

Команды

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

Локальный смоук-тест:

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"}'

Развёртывание

Docker (рекомендуется)

Многоступенчатый Dockerfile (node:22-slim), среда выполнения как непривилегированный пользователь mcp, /data как том для SQLite, healthcheck через /health. В продакшене развёртывание автоматическое через .github/workflows/deploy.yml (push тега vX.Y.Z → сборка в GHCR → docker run на VPS). Вручную:

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

Резервное копирование 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= — это нативный эквивалент systemd для .env. Используйте выделенного пользователя (mcp) вместо root.

Настройка в MCP-клиенте

Claude Desktop / CLI (статический bearer)

{
  "mcpServers": {
    "ipbx": {
      "type": "http",
      "url": "https://mcp.ipbx.vivavox.com.br/mcp",
      "headers": {
        "Authorization": "Bearer SEU_MCP_AUTH_TOKEN"
      }
    }
  }
}

claude.ai (OAuth)

Добавить как Custom Connector, используя https://mcp.ipbx.vivavox.com.br/mcp. Поток OAuth запускается автоматически — claude.ai обнаруживает AS через WWW-Authenticate, регистрирует клиента через DCR, перенаправляет на Google, получает code и обменивает его на access token.

Структура

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