ipbx-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ipbx-mcplist all queues"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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-sqlite3v12 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 buildConfiguration
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 |
| Static bearer — Claude Desktop, CLI, API, scripts, cron |
| OAuth — clients via claude.ai (web/mobile) |
OAuth (optional, but required for claude.ai)
Variable | Description |
| Canonical server URL (e.g. |
| HS256 key for JWTs (32 bytes hex) |
| From the OAuth Client in Google Cloud Console |
| From the OAuth Client in Google Cloud Console |
| Allowed Workspace domain (default: |
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 |
|
| HTTP port |
|
| Interface (use |
| — | CSV list of hosts accepted in the |
|
| SQLite file path |
| — | Base URL of the IPBX API that serves recordings (e.g. |
MySQL (IPBX data source)
Variable | Default | Description |
| — | MySQL host |
|
| |
| — | Use a dedicated user with |
| — | |
| — | |
|
| Pool size ( |
| empty | Any value enables TLS with cert verification |
| — | 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 |
| bearer | MCP JSON-RPC via Streamable HTTP |
GET |
| bearer |
|
DELETE |
| bearer |
|
GET |
| public |
|
GET |
| public | RFC 8414 metadata |
GET |
| public | RFC 9728 metadata |
POST |
| public | Dynamic Client Registration (RFC 7591) |
GET |
| public | Redirects to Google |
GET |
| public | Receives the Google redirect |
POST |
| public |
|
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 namelimit(number, optional): 1–500, default100
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 emaillimit(number, optional): 1–500, default100
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 descriptionlimit(number, optional): 1–500, default100
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 hostlimit(number, optional): 1–500, default100
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 alllimit(number, optional): 1–500, default200
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 alllimit(number, optional): 1–500, default200
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 InspectorLocal 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
Docker (recommended)
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-mcpSQLite 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.targetEnvironmentFile= 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 VPSThis server cannot be installed
Maintenance
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
Query, browse, and automate OmegaAI workspaces from any MCP client. Streamable HTTP with OAuth 2.0.
The official MCP Server from Mia-Platform to interact with Mia-Platform Console
Streamable HTTP MCP server exposing planner flows, tasks, and squads.
MCP server for Codat — companies, connections, invoices, bills and financial statements.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceRemote MCP server for Odoo ERP — exposes Odoo operations over Streamable HTTP with bearer token authentication.MIT
- FlicenseAqualityBmaintenanceAn 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.11
- FlicenseNot gradedqualityDmaintenanceA standalone MCP server that exposes API endpoints as tools for AI assistants, using SSE transport.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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