Skip to main content
Glama
paralelum

ipbx-mcp

by paralelum

ipbx-mcp

IPBX MCP server in TypeScript. Streamable HTTP transport in stateless mode, authentication via static bearer and/or OAuth 2.1 + Google Workspace, local persistence in SQLite (OAuth clients, refresh tokens, audit log). Inherited from the base-mcp scaffold, it exposes PABX (MySQL) data as typed tools.

Public URL in production: https://mcp.ipbx.vivavox.com.br.

Requirements

  • Node.js >= 22 (better-sqlite3 v12 requires it)

  • For OAuth: OAuth Client in Google Cloud Console in Internal mode

Related MCP server: utel-mcp

Installation

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

Configuration

Load the .env in the process (systemd EnvironmentFile=, docker env_file:, or node --env-file=.env at startup).

Required

At least one of the auth paths:

Variable

When to use

MCP_AUTH_TOKEN

Static bearer — Claude Desktop, CLI, API, scripts, cron

OAUTH_JWT_SECRET + OAUTH_ISSUER

OAuth — clients via claude.ai (web/mobile)

OAuth (optional, but required for claude.ai)

Variable

Description

OAUTH_ISSUER

Canonical server URL (e.g. https://mcp.ipbx.vivavox.com.br)

OAUTH_JWT_SECRET

HS256 key for JWTs (32 bytes hex)

GOOGLE_CLIENT_ID

From the OAuth Client in Google Cloud Console

GOOGLE_CLIENT_SECRET

From the OAuth Client in Google Cloud Console

ALLOWED_GOOGLE_HD

Allowed Workspace domain (default: vivavox.com.br)

When all are present, the /authorize, /oauth/google/callback, /token and /register (DCR) routes are mounted. Without them, only static bearer works.

Others

Variable

Default

Description

PORT

3000

HTTP port

HOST

0.0.0.0

Interface (use 127.0.0.1 in local dev)

MCP_ALLOWED_HOSTS

CSV list of hosts accepted in the Host header

SQLITE_PATH

./data/app.db

SQLite file path

IPBX_RECORD_BASE_URL

Base URL of the IPBX API that serves recordings (e.g. https://ipbx.vivavox.com.br/api). Without it, ipbx_recording_get fails

MySQL (IPBX data source)

Variable

Default

Description

MYSQL_HOST

MySQL host

MYSQL_PORT

3306

MYSQL_USER

Use a dedicated user with GRANT SELECT only

MYSQL_PASSWORD

MYSQL_DATABASE

MYSQL_POOL_LIMIT

5

Pool size (mysql2)

MYSQL_SSL

empty

Any value enables TLS with cert verification

IPBX_ID

Tenant this instance serves (see below)

The database is multi-tenant — one Asterisk instance per client, ipbx table — but each MCP instance serves a single tenant. All queries filter by IPBX_ID, and no tool accepts that id as a parameter: this way, isolation between clients does not depend on what the model passes in the call. One container and one subdomain per tenant.

Generate random tokens with:

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

Endpoints

Method

Path

Auth

Description

POST

/mcp

bearer

MCP JSON-RPC via Streamable HTTP

GET

/mcp

bearer

405

DELETE

/mcp

bearer

405

GET

/health

public

{"status":"ok"}

GET

/.well-known/oauth-authorization-server

public

RFC 8414 metadata

GET

/.well-known/oauth-protected-resource

public

RFC 9728 metadata

POST

/register

public

Dynamic Client Registration (RFC 7591)

GET

/authorize

public

Redirects to Google

GET

/oauth/google/callback

public

Receives the Google redirect

POST

/token

public

authorization_code / refresh_token

401 on /mcp includes WWW-Authenticate: Bearer realm=..., resource_metadata=... — without it, claude.ai cannot discover the AS on first contact.

Available tools

Tool names follow ipbx_<model>_<action>, with <action> in the list / get / search / count vocabulary.

ipbx_instance_get

Registration data of the IPBX instance this server serves — name, IP, and SIP/AMI ports.

Parameters: none. The instance is fixed, defined by IPBX_ID in the environment.

Return:

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

Returns isError if the configured IPBX_ID does not exist in the ipbx table.

ipbx_branch_list

Lists the extensions of the instance.

Parameters:

  • search (string, optional): partial search by extension number or name

  • limit (number, optional): 1–500, default 100

Return:

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

Does not return SIP credentials. The password (plaintext password) and username (authentication identifier, different from the extension number) columns are left out by design — together they allow registering a softphone and originating calls on the client's account. The column list in the SELECT is explicit precisely so that none of them slip in by accident.

ipbx_user_list

Lists the panel users of the instance.

Parameters:

  • search (string, optional): partial search by name or email

  • limit (number, optional): 1–500, default 100

Return:

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

Does not return the access password. The secret column is left out: it is the panel login password, stored in plain text in the database (no hash). Exposing it would hand over administrative access to the PABX.

ipbx_group_list

Lists the extension groups of the instance, with how many extensions each has.

Parameters:

  • search (string, optional): partial search by name or description

  • limit (number, optional): 1–500, default 100

Return:

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

The groups table does not store credentials — unlike branch and users, here all columns are exposed.

ipbx_trunk_list

Lists the trunks of the instance.

Parameters:

  • search (string, optional): partial search by name or host

  • limit (number, optional): 1–500, default 100

Return:

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

Does not return the carrier credentials. username and password are left out — they are the most valuable credentials in the database, since they allow originating calls directly through the carrier, billed to the account. In their place comes auth, which only says how the trunk authenticates: "credentials" (user/password) or "ip" (IP allowlist, no password).

ipbx_queue_list

Lists the call queues, with the distribution strategy and how many members each has.

Parameters: search (string, optional), 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

Lists the queue members, in ring order.

Parameters:

  • queue_id (number, optional): filters one queue; omit to bring all

  • limit (number, optional): 1–500, default 200

Return:

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

The queue_member.member column stores a reference in the <type>-<id> format — branch-10 points to branch.id 10, which is extension 29. It is not the extension number. The tool resolves this to exten + name when the member is an extension. Not every member is: there are redirect-N entries, which come back with type: "redirect" and null exten/name.

ipbx_ivr_list

Lists the IVRs, with the associated audio and the transcription of what is spoken to the caller.

Parameters: search (string, optional — matches the name or the transcription text), 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
    }
  ]
}

The transcription is the most useful field: it lets you find an IVR by what it says, not just by its name.

ipbx_ivr_option_list

Lists the IVR options — which key leads to which destination.

Parameters:

  • ivr_id (number, optional): filters one IVR; omit to bring all

  • limit (number, optional): 1–500, default 200

Return:

{
  "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 is polymorphic: it points to 5 different tables (branch, queue, ivr, redirect, app) in the <type>-<id> format, and also accepts literals without an id (internal). The tool resolves the destination name in all cases; literals come back with null name and the ref preserved.

The digit field is not always a digit: t is timeout and patterns like 7X match extension ranges.

ipbx_redirect_list

Lists the redirects — short extensions that forward to an external number through a trunk. These are the same redirect-<id> entries that appear as destinations in queues, IVRs, and routing rules.

Parameters: search (string, optional — matches extension, name, or number), limit (1–500, default 100)

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

⚠️ Personal data. forward is a personal mobile number in 100% of the rows — it is not a credential, but it is personal data under LGPD. The tool returns it because it is the reason the table exists, but it does not go to the audit_log.

ipbx_routing_list

Lists the routing plans, with how many rules and time windows each has.

Parameters: search (string, optional), 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

Lists the time windows of the plans.

Parameters: routing_id (number, optional), 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"]
}

The pattern is stored in Asterisk format, one range per line; the tool returns it as a list.

ipbx_routing_rule_list

Lists the routing rules — the dialplan. Each rule matches a number pattern within a time window, suppresses digits, adds a prefix, and sends to the destination.

Parameters: routing_id (number, optional), 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 is polymorphic like the IVR one, plus the trunk type (used in outbound rules) — six possible destinations in total.

Two schema details handled here: the database column is called supress (with one "p"), exposed as suppress; and goto2/goto3 exist but are empty in all rows — they appear as goto_extra only if they are ever filled.

ipbx_cdr_list

Call history. The period is required and limited to 31 days: the cdr has no index beyond the PK, so every filter is a full scan (~268k rows today).

Parameters: date_from and date_to (YYYY-MM-DD, required), scope (call | leg, default call), src and dst (partial), 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
}

The cdr is the only PABX table without ipbx_id. The link to the tenant is the Asterisk systemname, which ipbx-api writes as sip<ipbx_id> and Asterisk stamps into the uniqueid/linkedid of each row — the filter is uniqueid LIKE 'sip<id>-%', with the hyphen (without it, sip1 would also match sip10-).

A call is many rows: uniqueid identifies the channel, linkedid the call, and each Dial attempt generates a row — a queue inbound call can reach 22. scope=call groups by linkedid; the legs whose destination is a Local/ channel are the queue ringing each member (they become ring_attempts) and the rest are conversation (they add up to talk_seconds). scope=leg returns the raw legs — use it with call_id to debug a call.

has_recording requires ANSWERED in addition to rec being filled — same rule as the panel's recAvailable. The rec column is written before the Dial (the dialplan sets up MixMonitor in prerouting), so it marks "recording armed" rather than "audio exists": on its own, it would report recording on 99.96% of calls.

No channel column comes out raw: channel, dstchannel, and lastdata carry the endpoint username, which is half of the SIP credential, and src carries that same username on internal calls. Everything goes through src/channel.ts and comes out as extension/trunk/queue. rec is also left out — it becomes has_recording.

Filtering by branch_id/trunk_id selects calls by semi-join, not by row: the aggregates still describe the whole call, not just that extension's legs.

ipbx_recording_get

Audio URL for one call, from the call_id that ipbx_cdr_list returns.

Parameters: call_id (string, required)

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

It's a separate tool rather than a field of ipbx_cdr_list for one reason: the /call/record route on ipbx-api requires no authentication and the URL never expires — the file name (SHA1) is the credential. As a listing field, each CDR call would dump 25 permanent accesses to conversations into the context, almost all never used, and the audit would have to log 25 credentials or log nothing. One tool per recording gives an audit line with the identity of who requested it. The CDR's has_recording is the discovery signal; this tool is the access.

Without audio, the response states the reason instead of just denying — Chamada nao atendida (the recording is armed before the Dial) or recording disabled on the extension. A call_id from another tenant returns isError: the IPBX_ID filter is applied in the query, and the sip<id> in the URL comes from the environment, never from the received call_id.

It depends on IPBX_RECORD_BASE_URL. Without it, the server starts normally and only this tool fails, with an explicit message — it's like disabling the tool on a server.

Every call generates a row in audit_log with the caller's identity: Google email if JWT, service:static if static bearer. src/dst from ipbx_cdr_list are phone numbers and don't go to the audit — only number_filter: true remains.

Commands

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

Local smoke test:

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

Multi-stage Dockerfile (node:22-slim), runtime as non-root user mcp, exposes /data as a volume for SQLite, healthcheck via /health. In production, deploy is automatic via .github/workflows/deploy.yml (push of tag vX.Y.Z → build on GHCR → docker run on the VPS). Manually:

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 backup:

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= is systemd's native equivalent of .env. Use a dedicated user (mcp) instead of root.

Setting up in an MCP client

Claude Desktop / CLI (static bearer)

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

claude.ai (OAuth)

Add as a Custom Connector using https://mcp.ipbx.vivavox.com.br/mcp. The OAuth flow triggers automatically — claude.ai discovers the AS via WWW-Authenticate, registers a client via DCR, redirects to Google, receives the code, and exchanges it for an access token.

Structure

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

Maintenance

ActivityMaintained
ResponsivenessSyncing

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    B
    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.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/paralelum/ipbx-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server