MCP-сервер своей АТС
Для владельца бизнеса и интегратора: подключите АТС к ИИ-ассистенту — Claude, ChatGPT, Cursor, VS Code — и спрашивайте о звонках обычными словами, перезванивайте клиентам и меняйте настройки в пределах выданных прав. MCP сообщает ассистенту, какие действия доступны и с какими параметрами; ассистент сам выбирает нужное. Ключи, права и OAuth описаны в обзоре API и MCP своей АТС.
В статье: адрес, подключение по ключу, подключение без ключа, инструменты, диалоги, JSON-RPC вручную, ошибки.
Адрес сервера#
Адрес MCP-сервера — https://api.cenaly.ru/pbx-api/mcp (streamable HTTP, JSON-RPC 2.0, без сессий). Он один на аккаунт и для подключения по ключу, и для коннекторов по OAuth. Точку ассистент выбирает параметром location — список точек даёт инструмент list_locations.
У точки со своей АТС позже появится и прямой адрес MCP на самом сервере АТС. Пока эта возможность внедряется, подключайте адрес выше — он остаётся рабочим и после её появления.
Подключение по ключу#
Владелец открывает Телефония → API и MCP → Ключи → Создать ключ, выбирает права, точки и срок. Сохраните ключ cpx_…: он показывается один раз, вместе с готовыми вставками для подключения. Для начала подойдёт набор «ИИ-ассистент»; право закрывать точку daynight:write добавляется отдельной галкой.
Claude Code#
claude mcp add --transport http telephony https://api.cenaly.ru/pbx-api/mcp --header "Authorization: Bearer cpx_…"
claude mcp list
В Claude Code команда /mcp показывает состояние подключения. Формат команды — в документации Claude Code.
Cursor#
Добавьте сервер в .cursor/mcp.json; файл с настоящим ключом не кладите в git.
{
"mcpServers": {
"telephony": {
"url": "https://api.cenaly.ru/pbx-api/mcp",
"headers": {
"Authorization": "Bearer cpx_…"
}
}
}
}
VS Code#
Файл .vscode/mcp.json использует ключ servers, а не mcpServers. Запустите сервер командой MCP: List Servers. Формат — в документации VS Code.
{
"servers": {
"telephony": {
"type": "http",
"url": "https://api.cenaly.ru/pbx-api/mcp",
"headers": {
"Authorization": "Bearer cpx_…"
}
}
}
}
Claude Desktop#
Локальный claude_desktop_config.json подключает удалённый сервер с ключом через мост mcp-remote: нужен Node.js с npx, мост запускается на вашем компьютере. Откройте файл через настройки разработчика Claude Desktop, вставьте конфигурацию и полностью перезапустите приложение.
{
"mcpServers": {
"telephony": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.cenaly.ru/pbx-api/mcp",
"--header",
"Authorization:${PBX_AUTH}"
],
"env": {
"PBX_AUTH": "Bearer cpx_…"
}
}
}
}
Файл содержит ключ — ограничьте доступ к нему. Без моста и без ключа Claude Desktop подключается коннектором по OAuth (следующий раздел).
Подключение без ключа: OAuth#
Облачные ассистенты подключают тот же адрес https://api.cenaly.ru/pbx-api/mcp по OAuth: ключ создавать не нужно, владелец входит в свой аккаунт и разрешает доступ.
| Клиент | Где добавить |
|---|---|
| claude.ai, Claude Desktop | Настройки → Коннекторы → Добавить свой коннектор → URL сервера; если спросят про OAuth-клиента — автоматическая регистрация |
| ChatGPT | Свой MCP-сервер (custom MCP server) → URL сервера → аутентификация OAuth |
| Perplexity | Настройки аккаунта → Коннекторы → свой удалённый коннектор → URL и OAuth |
| Cursor, VS Code | удалённый (HTTP) сервер с тем же URL без заголовка с ключом — окно входа откроется в браузере |
Пункты меню зависят от версии клиента и тарифа вашего аккаунта в нём; актуальные пути — у самих клиентов: Claude, ChatGPT, VS Code.
Дальше ассистент откроет окно входа Cenaly: владелец видит, какому адресу уходит доступ, отмечает права и точки и нажимает «Разрешить». Подключение появляется в «Ключах» с меткой OAuth — там же меняются права и отзывается доступ. Что показывает окно согласия и как устроен OAuth для своих коннекторов — в обзоре API и MCP своей АТС.
Каталог инструментов#
Клиент видит только инструменты, разрешённые ключом или подключением. «Меняет» — правка настроек, отправка теста или запуск звонка; чтение состояния ничего не меняет, даже если право называется write или dial.
| Инструмент | Что делает | Право | Меняет | Пример фразы |
|---|---|---|---|---|
list_locations |
точки и сведения об их АТС | — | нет | Какие точки доступны? |
list_calls |
журнал звонков с фильтрами | calls:read |
нет | Кто не дозвонился сегодня? |
get_call |
звонок и путь маршрутизации | calls:read |
нет | Почему этот звонок попал на 101? |
get_call_recording |
временная ссылка на запись | recordings:read |
нет | Дай запись этого звонка |
get_call_transcript |
расшифровка и итог разговора | recordings:read |
нет | Покажи расшифровку |
call_number |
звонок через телефон сотрудника | calls:dial |
да | Позвони клиенту с 101 |
get_dial_status |
как идёт звонок в клик | calls:dial |
нет | Дозвон начался? |
list_extensions |
сотрудники, группы, комнаты | extensions:read |
нет | Кто работает на номере 101? |
set_forwarding |
личная переадресация | extensions:write |
да | Переводи звонки 102 на 101 |
set_do_not_disturb |
включить или снять «не беспокоить» | extensions:write |
да | Не беспокоить 102 до утра |
get_presence |
кто свободен прямо сейчас | presence:read |
нет | Кто сейчас свободен? |
get_call_metrics |
аналитика за период | reports:read |
нет | Сравни сентябрь с августом |
list_routes |
линии, правила входящих и исходящих | routes:read |
нет | Куда идут входящие? |
list_route_history |
версии правил | routes:read |
нет | Что менялось во входящих? |
save_inbound_rule |
создать или изменить входящее правило | routes:write |
да | Назови правило «Дежурный» |
delete_inbound_rule |
удалить входящее правило | routes:write |
да | Удали это правило |
move_inbound_rule |
переставить входящее правило | routes:write |
да | Поставь его первым |
save_outbound_rule |
создать или изменить исходящее правило | routes:write |
да | Пусти этот шаблон через вторую линию |
delete_outbound_rule |
удалить исходящее правило | routes:write |
да | Удали правило исходящих |
move_outbound_rule |
переставить исходящее правило | routes:write |
да | Перемести его на второе место |
set_outbound_calls |
исходящие и линия по умолчанию | routes:write |
да | Выключи внешние исходящие |
restore_route_version |
откат правил к версии | routes:write |
да | Верни вчерашние правила |
export_config |
таблицы настроек без паролей | config:export |
нет | Выгрузи настройки |
import_config |
предпросмотр и применение таблиц | config:import |
да, при apply |
Сначала покажи изменения файла |
get_call_events |
лента событий звонков | calls:read |
нет | Какие события пришли после этого? |
list_webhooks |
подписки на события | webhooks:write |
нет | Какие вебхуки включены? |
save_webhook |
создать или изменить подписку | webhooks:write |
да | Отправляй завершения звонков в CRM |
delete_webhook |
удалить подписку | webhooks:write |
да | Удали старый вебхук |
rotate_webhook_secret |
сменить секрет подписи | webhooks:write |
да | Выпусти новый секрет |
test_webhook |
отправить webhook.ping |
webhooks:write |
да | Проверь доставку |
list_webhook_deliveries |
журнал попыток доставки | webhooks:write |
нет | Почему CRM не получает события? |
get_open_closed |
открыта ли точка | routes:read |
нет | Точка закрыта вручную? |
set_open_closed |
открыть или закрыть точку | daynight:write |
да | Закрой до завтра 09:00 |
Параметры и ответы инструментов совпадают с методами REST — подробности в статье REST API своей АТС.
Примеры диалогов#
«Кто пропустил звонки сегодня?» Ассистент уточняет точку и её часовой пояс, вызывает list_calls с status:"missed", since = начало местного дня и until = начало следующего, читает все страницы по nextCursor, а для разбора конкретного звонка — get_call. Пропущенный звонок не всегда относится к одному сотруднику: могли звонить сразу несколько телефонов.
«Включи переадресацию на 101». Ассистент уточняет, с какого добавочного и при каком условии. После ответа «все звонки 102» вызывает:
{
"name": "set_forwarding",
"arguments": {
"location": "loc1",
"extension": "102",
"forward": {
"always": {
"kind": "extension",
"extension": "101"
}
}
}
}
forward:null снимает переадресацию. Внешний номер задаётся как {"kind":"number","number":"+995555123456"} — такая переадресация расходует минуты.
«Закрой точку до утра». Ассистент уточняет дату и время открытия, проверяет get_open_closed и после подтверждения вызывает set_open_closed с closed:true и until, например 2026-10-25T09:00:00+04:00. Нужны право daynight:write и включённое закрытие кодом *28. Без until точка закрыта до конца суток, а не до утра; срок — не дальше 30 дней. closed:false открывает точку.
JSON-RPC вручную#
POST с JSON, без сессии. Поддерживаются версии протокола 2025-06-18, 2025-03-26, 2024-11-05. Примеры ответов составлены по коду на вымышленных данных.
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."
}
}
Затем уведомление о готовности (ответ 202 без тела) и каталог:
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"}'
Ответ для ключа с единственным правом calls:read (описания и схемы присылает сервер):
{
"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
}
}
]
}
}
Вызов инструмента:
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}}}'
Ответ, если событий нет:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "{\"location\":\"loc1\",\"events\":[],\"more\":false}"
}
],
"structuredContent": {
"location": "loc1",
"events": [],
"more": false
},
"isError": false
}
}
Ошибку инструмента сервер возвращает с HTTP 200: проверяйте result.isError, причина — JSON в content[].text, например {"error":"scope_required","scope":"calls:read"}. Неизвестный инструмент — ошибка JSON-RPC -32602, неизвестный метод — -32601, битый JSON — -32700.
Ошибки и безопасность#
| Симптом | Что сделать |
|---|---|
| HTTP 401 | проверьте заголовок, срок и отзыв ключа; для OAuth обновите токен или переподключитесь |
scope_required или нужного инструмента нет |
добавьте право в «Права» ключа и обновите список инструментов в клиенте |
location_required |
сначала list_locations, затем передавайте location |
location_not_found |
точки нет или ключ ограничен другими точками |
429 rate_limited |
подождите Retry-After; лимиты общие для REST и MCP |
| GET на адрес MCP не работает | это адрес для POST JSON-RPC, а не веб-страница |
| 409 при закрытии точки | включите закрытие кодом *28 и проверьте дату until |
Лимиты: 120 запросов в минуту и 20 000 в сутки; звонки в клик — отдельно 10 в минуту и 300 в сутки. Не повторяйте call_number вслепую после сетевого сбоя: сначала проверьте get_dial_status и журнал.
Права routes:write, config:export, config:import, webhooks:write, daynight:write выдаются только отдельной галкой и не входят даже в набор «Всё». Ключ действует от имени владельца: ограничьте его точками и сроком, не вставляйте ключ в адрес или в текст диалога с ассистентом. Номера и записи — персональные данные ваших клиентов. Проверяйте, что собирается сделать ассистент, прежде чем он изменит правила или позвонит. Импорт настроек — сначала без apply, затем с baseRev из предпросмотра.