Your PBX MCP server
For business owners and integrators: connect your PBX to an AI assistant — Claude, ChatGPT, Cursor, VS Code — and ask about calls in plain words, call customers back and change settings within the permissions you granted. MCP tells the assistant which actions are available and with which parameters; the assistant picks the right one itself. Keys, permissions and OAuth are covered in the overview Your PBX API and MCP.
In this article: address, connecting with a key, connecting without a key, tools, conversations, raw JSON-RPC, errors.
Server address#
The MCP server address is https://api.cenaly.ru/pbx-api/mcp (streamable HTTP, JSON-RPC 2.0, no sessions). There is one per account, both for connecting with a key and for OAuth connectors. The assistant picks a location with the location parameter — the list_locations tool returns the list of locations.
A location with its own PBX will later also get a direct MCP address on the PBX server itself. While this is being rolled out, connect the address above — it stays available after that too.
Connecting with a key#
The owner opens Telephony → API and MCP → Keys → Create key and chooses permissions, locations and a term. Save the cpx_… key: it is shown once, together with ready-made connection snippets. The "AI assistant" preset is a good start; the permission to close the location, daynight:write, is added with a separate checkbox.
Claude Code#
claude mcp add --transport http telephony https://api.cenaly.ru/pbx-api/mcp --header "Authorization: Bearer cpx_…"
claude mcp list
In Claude Code, the /mcp command shows the connection status. The command format is in the Claude Code documentation.
Cursor#
Add the server to .cursor/mcp.json; do not commit a file with the real key to git.
{
"mcpServers": {
"telephony": {
"url": "https://api.cenaly.ru/pbx-api/mcp",
"headers": {
"Authorization": "Bearer cpx_…"
}
}
}
}
VS Code#
The .vscode/mcp.json file uses the servers key, not mcpServers. Start the server with the MCP: List Servers command. The format is in the VS Code documentation.
{
"servers": {
"telephony": {
"type": "http",
"url": "https://api.cenaly.ru/pbx-api/mcp",
"headers": {
"Authorization": "Bearer cpx_…"
}
}
}
}
Claude Desktop#
The local claude_desktop_config.json connects a remote server with a key through the mcp-remote bridge: you need Node.js with npx, and the bridge runs on your computer. Open the file from Claude Desktop's developer settings, paste the configuration and fully restart the app.
{
"mcpServers": {
"telephony": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.cenaly.ru/pbx-api/mcp",
"--header",
"Authorization:${PBX_AUTH}"
],
"env": {
"PBX_AUTH": "Bearer cpx_…"
}
}
}
}
The file contains the key — restrict access to it. Without the bridge and without a key, Claude Desktop connects as an OAuth connector (next section).
Connecting without a key: OAuth#
Cloud assistants connect the same address https://api.cenaly.ru/pbx-api/mcp with OAuth: no key is needed, the owner signs in to their account and allows access.
| Client | Where to add it |
|---|---|
| claude.ai, Claude Desktop | Settings → Connectors → Add custom connector → server URL; if asked about the OAuth client — automatic registration |
| ChatGPT | Custom MCP server → server URL → OAuth authentication |
| Perplexity | Account settings → Connectors → custom remote connector → URL and OAuth |
| Cursor, VS Code | a remote (HTTP) server with the same URL and no key header — the sign-in window opens in the browser |
Menu items depend on the client version and on your account's plan in it; the current paths are with the clients themselves: Claude, ChatGPT, VS Code.
The assistant then opens the Cenaly sign-in window: the owner sees which address gets access, ticks permissions and locations and clicks "Allow". The connection appears in "Keys" with the OAuth badge — permissions are changed and access is revoked there. What the consent window shows and how OAuth works for your own connectors — in the overview Your PBX API and MCP.
Tool catalogue#
A client sees only the tools allowed by its key or connection. "Changes" means editing settings, sending a test or starting a call; reading a state changes nothing, even if the permission is called write or dial.
| Tool | What it does | Permission | Changes | Example phrase |
|---|---|---|---|---|
list_locations |
locations and their PBX summary | — | no | Which locations are available? |
list_calls |
call log with filters | calls:read |
no | Who couldn't get through today? |
get_call |
a call and its routing path | calls:read |
no | Why did this call go to 101? |
get_call_recording |
temporary link to the recording | recordings:read |
no | Give me the recording of this call |
get_call_transcript |
transcript and call summary | recordings:read |
no | Show the transcript |
call_number |
a call through an employee's phone | calls:dial |
yes | Call the customer from 101 |
get_dial_status |
how a click-to-call is going | calls:dial |
no | Has the call started? |
list_extensions |
employees, groups, rooms | extensions:read |
no | Who is on extension 101? |
set_forwarding |
personal forwarding | extensions:write |
yes | Forward 102's calls to 101 |
set_do_not_disturb |
turn do not disturb on or off | extensions:write |
yes | Do not disturb 102 until morning |
get_presence |
who is free right now | presence:read |
no | Who is free now? |
get_call_metrics |
analytics for a period | reports:read |
no | Compare September with August |
list_routes |
lines, inbound and outbound rules | routes:read |
no | Where do inbound calls go? |
list_route_history |
rule versions | routes:read |
no | What changed in inbound rules? |
save_inbound_rule |
create or change an inbound rule | routes:write |
yes | Name the rule "On duty" |
delete_inbound_rule |
delete an inbound rule | routes:write |
yes | Delete this rule |
move_inbound_rule |
reorder an inbound rule | routes:write |
yes | Put it first |
save_outbound_rule |
create or change an outbound rule | routes:write |
yes | Send this pattern through the second line |
delete_outbound_rule |
delete an outbound rule | routes:write |
yes | Delete the outbound rule |
move_outbound_rule |
reorder an outbound rule | routes:write |
yes | Move it to second place |
set_outbound_calls |
outbound calls and the default line | routes:write |
yes | Turn off external outbound calls |
restore_route_version |
restore rules to a version | routes:write |
yes | Bring back yesterday's rules |
export_config |
settings tables without passwords | config:export |
no | Export the settings |
import_config |
preview and apply tables | config:import |
yes, with apply |
First show what the file changes |
get_call_events |
call event feed | calls:read |
no | Which events came after this one? |
list_webhooks |
event subscriptions | webhooks:write |
no | Which webhooks are on? |
save_webhook |
create or change a subscription | webhooks:write |
yes | Send call endings to the CRM |
delete_webhook |
delete a subscription | webhooks:write |
yes | Delete the old webhook |
rotate_webhook_secret |
change the signing secret | webhooks:write |
yes | Issue a new secret |
test_webhook |
send webhook.ping |
webhooks:write |
yes | Test the delivery |
list_webhook_deliveries |
delivery attempt log | webhooks:write |
no | Why isn't the CRM getting events? |
get_open_closed |
whether the location is open | routes:read |
no | Is the location closed manually? |
set_open_closed |
open or close the location | daynight:write |
yes | Close until 09:00 tomorrow |
Tool parameters and responses match the REST methods — details in Your PBX REST API.
Example conversations#
"Who missed calls today?" The assistant clarifies the location and its time zone, calls list_calls with status:"missed", since = start of the local day and until = start of the next one, reads every page via nextCursor, and for a specific call — get_call. A missed call does not always belong to one employee: several phones may have rung at once.
"Turn on forwarding to 101." The assistant asks from which extension and under what condition. After the answer "all calls of 102" it calls:
{
"name": "set_forwarding",
"arguments": {
"location": "loc1",
"extension": "102",
"forward": {
"always": {
"kind": "extension",
"extension": "101"
}
}
}
}
forward:null removes forwarding. An external number is given as {"kind":"number","number":"+995555123456"} — such forwarding spends minutes.
"Close the location until morning." The assistant clarifies the opening date and time, checks get_open_closed and after confirmation calls set_open_closed with closed:true and until, for example 2026-10-25T09:00:00+04:00. It needs the daynight:write permission and closing with the *28 code turned on. Without until the location is closed until the end of the day, not until morning; the term is at most 30 days. closed:false opens the location.
Raw JSON-RPC#
POST with JSON, no session. Supported protocol versions: 2025-06-18, 2025-03-26, 2024-11-05. The response examples are built from the code with made-up data.
MCP='https://api.cenaly.ru/pbx-api/mcp'
KEY='cpx_…'
curl -sS "$MCP" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"example","version":"1.0"}}}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {
"listChanged": false
}
},
"serverInfo": {
"name": "cenaly-pbx",
"title": "Cenaly PBX",
"version": "1.0.0"
},
"instructions": "Cenaly phone system (PBX) of the key owner. Start with list_locations if unsure which location to use. For \"who called today\" use list_calls with since = start of today in the location time zone. Phone numbers are personal data: show them only to the account owner."
}
}
Then the ready notification (202 response with no body) and the catalogue:
curl -sS "$MCP" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -sS "$MCP" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
The response for a key with the single permission calls:read (descriptions and schemas come from the server):
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "list_locations",
"description": "Locations (business points) this key can see, with their phone system summary and `direct` — the address where the phone system serves this API itself (prefer it when ready).",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}
},
{
"name": "list_calls",
"description": "Phone system call log, newest first: who called whom, status (answered / missed / ai / other), duration. Use since/until (ISO time) for \"today\", status=\"missed\" for missed calls.",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Location id (from list_locations). Optional when the key has one location."
},
"since": {
"type": "string",
"description": "ISO date-time, inclusive"
},
"until": {
"type": "string",
"description": "ISO date-time, exclusive"
},
"direction": {
"type": "string",
"enum": [
"inbound",
"outbound",
"internal",
"transfer"
]
},
"status": {
"type": "string",
"description": "Status group answered|missed|ai|other or an exact status"
},
"number": {
"type": "string",
"description": "Part of the phone number (digits)"
},
"extension": {
"type": "string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"cursor": {
"type": "string"
}
},
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}
},
{
"name": "get_call",
"description": "One call with its routing trace: which rule matched, whose phone rang, who answered.",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Location id (from list_locations). Optional when the key has one location."
},
"id": {
"type": "string"
}
},
"required": [
"id"
],
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}
},
{
"name": "get_call_events",
"description": "Live call events of the last 7 days (up to 2000 per location), oldest first: call.started, call.ringing, call.answered, call.transferred, call.ended (who, which line, who answered, result). Poll with since = the \"next\" value of the previous answer to get only new events (also pages through the history: up to 500 per answer, \"more\" = there is a next page). For push delivery set up a webhook (save_webhook).",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Location id (from list_locations). Optional when the key has one location."
},
"since": {
"type": "string",
"description": "Event id from a previous answer (\"next\") or ISO time; only later events are returned"
},
"types": {
"type": "array",
"items": {
"type": "string",
"enum": [
"call.started",
"call.ringing",
"call.answered",
"call.transferred",
"call.ended"
]
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 500
}
},
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": false
}
}
]
}
}
Calling a tool:
curl -sS "$MCP" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_call_events","arguments":{"location":"loc1","limit":10}}}'
The response when there are no events:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"location\":\"loc1\",\"events\":[],\"more\":false}"
}
],
"structuredContent": {
"location": "loc1",
"events": [],
"more": false
},
"isError": false
}
}
The server returns a tool error with HTTP 200: check result.isError; the reason is JSON in content[].text, for example {"error":"scope_required","scope":"calls:read"}. An unknown tool is JSON-RPC error -32602, an unknown method — -32601, broken JSON — -32700.
Errors and security#
| Symptom | What to do |
|---|---|
| HTTP 401 | check the header, the key's term and revocation; for OAuth refresh the token or reconnect |
scope_required or a tool is missing |
add the permission in the key's "Permissions" and refresh the tool list in the client |
location_required |
call list_locations first, then pass location |
location_not_found |
no such location or the key is limited to other locations |
429 rate_limited |
wait Retry-After; the limits are shared by REST and MCP |
| GET on the MCP address does not work | this address is for POST JSON-RPC, not a web page |
| 409 when closing the location | turn on closing with the *28 code and check the until date |
Limits: 120 requests per minute and 20,000 per day; click-to-call — separately 10 per minute and 300 per day. Do not repeat call_number blindly after a network failure: check get_dial_status and the call log first.
routes:write, config:export, config:import, webhooks:write, daynight:write are granted only with a separate checkbox and are not part of even the "everything" preset. A key acts on behalf of the owner: limit it to locations and a term, and do not paste the key into an address or into the conversation with the assistant. Numbers and recordings are your customers' personal data. Check what the assistant is about to do before it changes rules or places a call. Settings import — first without apply, then with baseRev from the preview.