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

14 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 has two addresses (streamable HTTP, JSON-RPC 2.0, no sessions):

Address For whom What it does
https://sip-<location id>.cenaly.ge/pbx-api/mcp — direct, on the PBX server a location with its own PBX, connection with a key call log, recordings, click-to-call, forwarding, do not disturb, presence, events, open/closed — the one location of this PBX, without a hop through the cloud
https://api.cenaly.ge/pbx-api/mcp — cloud every location, key or OAuth all 33 tools, every location of the account; the assistant picks a location with the location parameter (the list — list_locations)

A location's direct address is in Telephony → API and MCP → How to connect (pick the location; "direct" means the address is ready) and in the direct field of the cloud GET /pbx-api/v1/me: the MCP address is direct.<location>.mcp, readiness is mcpReady:true (details). The location id in the server name is lowercase.

The direct MCP accepts only a cpx_… key — OAuth connectors connect to the cloud address. Its tools/list has no tools that the cloud executes: call rules, configuration, webhooks, metrics and transcripts. If you need them too, connect the cloud address as a second server with the same key; the direct server's instructions tell the assistant the same. The cloud address also works for a location with its own PBX — it is the fallback when the PBX server is unreachable.

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.

The examples below are for a location with its own PBX: the main server telephony is the direct address https://sip-loc1.cenaly.ge/pbx-api/mcp (use your location's address from the "How to connect" window), the second, telephony-cloud, is the cloud one for rules, configuration, webhooks, metrics and transcripts. For a location without its own PBX keep one server with the cloud address https://api.cenaly.ge/pbx-api/mcp.

Claude Code#

claude mcp add --transport http telephony https://sip-loc1.cenaly.ge/pbx-api/mcp --header "Authorization: Bearer cpx_…"
claude mcp add --transport http telephony-cloud https://api.cenaly.ge/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://sip-loc1.cenaly.ge/pbx-api/mcp",
      "headers": {
        "Authorization": "Bearer cpx_…"
      }
    },
    "telephony-cloud": {
      "url": "https://api.cenaly.ge/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://sip-loc1.cenaly.ge/pbx-api/mcp",
      "headers": {
        "Authorization": "Bearer cpx_…"
      }
    },
    "telephony-cloud": {
      "type": "http",
      "url": "https://api.cenaly.ge/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://sip-loc1.cenaly.ge/pbx-api/mcp",
        "--header",
        "Authorization:${PBX_AUTH}"
      ],
      "env": {
        "PBX_AUTH": "Bearer cpx_…"
      }
    },
    "telephony-cloud": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://api.cenaly.ge/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 cloud address https://api.cenaly.ge/pbx-api/mcp with OAuth (the direct PBX address does not accept 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. The cloud address serves the whole catalogue; the direct PBX address serves 13 tools that the PBX executes itself: list_locations, list_calls, get_call, get_call_recording, get_call_events, call_number, get_dial_status, list_extensions, set_forwarding, set_do_not_disturb, get_presence, get_open_closed, set_open_closed (the changing ones — call_number, set_forwarding, set_do_not_disturb, set_open_closed — have the same caveats as in REST: the PBX sends some cases to the cloud).

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.ge/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."
  }
}

The direct PBX address answers the same way, only instructions are extended: "This is the direct address of the phone system itself: it serves the call log, recordings, click-to-call, forwarding, do-not-disturb, presence, live call events and open/closed. Routing rules, configuration export/import, webhooks, call metrics and transcripts are served by the cloud MCP at https://api.cenaly.ge/pbx-api/mcp — connect it with the same key." Responses from the PBX server carry the X-Cenaly-Served-By: pbx header.

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; for this permission the direct PBX address returns the same four tools):

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

A cloud tool called by name on the direct address (for example, list_routes) returns isError:true with {"error":"served_by_cloud","cloudUrl":"https://api.cenaly.ge/pbx-api/mcp"} — call it through the cloud server with the same key.

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
served_by_cloud on the direct address the cloud executes this tool — connect the cloud address as a second server with the same key
503 lease_expired or direct_api_unavailable on the direct address the PBX has not confirmed the keys with the cloud for a long time or has not received them — use the cloud address for now
a new key gets 401 on the direct address the cloud has not passed the key to the PBX yet — usually seconds, up to 2–3 minutes for a freshly connected PBX

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#