C cenaly.ru
Start free

Your PBX MCP server

Connect your PBX to Claude, ChatGPT, Cursor and VS Code: with a key or with OAuth without a key, a catalogue of 33 tools, example conversations, raw JSON-RPC, errors and security

11 min read Admin panel demo
On this page 12

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.

See also#

Was this article helpful?