C cenaly.ru
Начать бесплатно

MCP-сервер своей АТС

Подключить АТС к Claude, ChatGPT, Cursor и VS Code: по ключу или по OAuth без ключа, каталог из 33 инструментов, примеры диалогов, JSON-RPC вручную, ошибки и безопасность

10 мин чтения Демо админ-панели
На этой странице 12

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 из предпросмотра.

Смотрите также#

Была ли статья полезной?