Написание расширения
Расширение добавляет в Moira типы нод в собственном пространстве имён, исходящие каналы
коммуникации или оба вида вкладов. Собственные ноды используют тот же граф, что и встроенные: Moira
проверяет конфигурацию, вызывает изолированный runner, сохраняет успешный результат под ID ноды и
при сбое переходит по её связи error, если она есть. Без этой связи Moira записывает диагностику
и приостанавливается на ноде для повторной попытки. Каналы коммуникации — это транспорты встроенной
универсальной ноды user-notification; они не являются типами нод и не получают идентификаторы
workflow или пользователя.
Код расширения не исполняется в процессах приложения Moira. Runner импортирует каждый бандл в отдельном дочернем процессе. Это изолирует падения и сроки выполнения, но не является песочницей для враждебного кода: администратор должен устанавливать только доверенные расширения.
Структура бандла
Каждый установленный каталог содержит манифест и точку входа:
my-extension/├── moira-extension.json└── index.tsRunner сканирует каталоги первого уровня внутри MOIRA_EXTENSIONS_DIR. Каталоги без манифеста
игнорируются. Некорректный бандл отвергается с указанием причин, остальные продолжают работать.
После добавления, удаления или изменения бандла перезапустите runner и Moira: оба каталога
загружаются при старте процессов.
Манифест
{ "apiVersion": "moira.extensions/v1", "name": "corporate-messenger", "version": "1.0.0", "entrypoint": "index.ts", "nodes": [ { "type": "corporate-messenger.send", "title": "Отправить сообщение", "description": "Отправляет сообщение в чат.", "configSchema": { "type": "object", "required": ["chat", "text"], "additionalProperties": false, "properties": { "chat": { "type": "string" }, "text": { "type": "string" } } }, "outputSchema": { "type": "object", "required": ["messageId"], "additionalProperties": false, "properties": { "messageId": { "type": "string" } } } } ], "communicationChannels": [ { "id": "corporate-messenger.notifications", "title": "Уведомления корпоративного мессенджера", "description": "Доставляет обычные уведомления в настроенный чат.", "capabilities": { "text": true, "image": false, "document": false, "trustedDelivery": false }, "configurationSchema": { "type": "object", "required": ["corporate-messenger.enabled", "corporate-messenger.default_chat"], "additionalProperties": false, "properties": { "corporate-messenger.enabled": { "type": "boolean" }, "corporate-messenger.default_chat": { "type": "string", "minLength": 1 } } }, "enabledSetting": "corporate-messenger.enabled", "settings": ["corporate-messenger.enabled", "corporate-messenger.default_chat"], "permissions": { "network": ["api.example.com"], "secrets": ["corporate-messenger.token"] } } ], "settings": [ { "key": "corporate-messenger.enabled", "type": "boolean", "label": "Включить уведомления", "defaultValue": "true" }, { "key": "corporate-messenger.default_chat", "type": "string", "label": "Чат уведомлений по умолчанию", "required": true }, { "key": "corporate-messenger.token", "type": "encrypted", "label": "Токен бота" } ], "permissions": { "network": ["api.example.com"], "secrets": ["corporate-messenger.token"] }}apiVersionдолжен быть равенmoira.extensions/v1.nameзадаёт пространство имён. Тип каждой ноды и ID каждого канала должны иметь вид<name>.<contribution>.entrypointдолжен разрешаться внутри каталога бандла.configSchema, необязательнаяinputSchemaиoutputSchema— JSON Schema, которые компилируются при регистрации манифеста. Конфигурация и вход проверяются до вызова, результат — до записи в состояние workflow.settingsобъявляет значения для универсального редактора настроек Moira.- Верхнеуровневый
permissionsвыдаёт службы только обработчикам нод. У каждого канала коммуникации отдельный объектpermissions; разрешения не переходят через эту границу.
Имена расширений, типы нод, ID каналов и ключи настроек должны быть уникальны. Ключ настройки обязан
находиться в пространстве имён расширения; пространства встроенных настроек Moira зарезервированы.
Бандл может не объявлять ноды и содержать только каналы; явный пустой массив nodes остаётся
допустимым для совместимости с форматом манифеста версии 1.
Настройки
Поддерживаются типы string, number, boolean, json и encrypted. Определения остаются в
манифесте, а не в базе. Удаление бандла скрывает определения; пользовательские значения сохраняются
и снова становятся доступны после установки тех же определений.
Значения encrypted хранятся зашифрованными и маскируются в публичных ответах. Объявление json
может содержать JSON Schema в поле validation: универсальный редактор принимает текст JSON,
проверяет разобранную структуру и сохраняет точное значение JSON. Обработчик получает такую
настройку как сериализованный текст JSON и разбирает её через JSON.parse.
Настройки принадлежат пользователям. Обработчик получает значения пользователя, чьё исполнение
запущено. Настройку adminOnly может изменить только администратор через аутентифицированный API или
MCP-инструмент настроек, но общей для установки она не становится. Используйте adminOnly только
для нод, которые должны выполняться от имени этого администратора.
Само объявление не выдаёт доступ. Чтобы прочитать значение, перечислите тот же ключ в
permissions.secrets и вызовите services.secret(key). Необъявленный или невыданный ключ
отвергается по имени, а выданный ключ без значения возвращает null.
Объявление канала коммуникации
Каждая запись communicationChannels объявляет один обычный исходящий транспорт:
idстабилен, находится в пространстве имени расширения и содержит не более 128 символов.capabilitiesуказывает поддержку текста, изображений и документов. ПолеtrustedDeliveryтолько запрашивает возможность доставки чувствительных сообщений и само ничего не разрешает.enabledSettingназывает объявленную логическую настройку пользователя. Значениеfalseотключает обычную доставку через этот канал для данного пользователя.settingsперечисляет несекретные псевдонимы, передаваемые обработчику как JSON.configurationSchemaпроверяет объект ровно из этих значений до доставки.permissions.networkиpermissions.secretsканала действуют только для его обработчика. Секреты читаются черезservices.secretи не должны одновременно входить вsettings.
Moira отвергает весь бандл, если канал ссылается на необъявленную настройку, использует чужое пространство имён, передаёт зашифрованное значение как обычную конфигурацию или расходится с объявлением точки входа. Значения включения, конфигурации и секретов разрешаются для текущего пользователя уведомления. Обработчик не получает его идентификатор, workflow, исполнение, репозиторий, артефакты или журналы.
Канал расширения участвует в веерной доставке встроенной ноды user-notification. Отсутствующий,
отключённый, некорректно настроенный или не поддерживающий тип сообщения канал пропускается. Сбой
health-проверки runner или обработчика считается неудачей канала с ограниченной очищенной причиной:
без успешного соседнего канала общий результат равен all_failed, а с ним — partial. Ни один из
результатов не раскрывает ответы эндпоинта или учётные данные.
Каждый установленный канал отображается карточкой в Settings > Notifications. Moira использует
title, description, capabilities, enabledSetting и точные псевдонимы настроек из объявления;
существующий редактор схем настроек отображает и сохраняет эти значения. Карточка показывает
состояние «готов», «отключён», «требуется настройка» или «недоступен» и отправляет фиксированный тест
через общий сервис с сохранёнными настройками текущего пользователя. Браузер передаёт только ID
канала и не отправляет секреты или адрес получателя повторно. Удаление бандла убирает карточку и
определения, но сохраняет пользовательские значения для переустановки. Зарегистрированный канал
остаётся видимым, когда он отключён или не проходит текущую health-проверку. Объявление trusted
delivery и одобрение администратора
показываются в пользовательской карточке без возможности изменения.
Обработчик
/* index.ts */ import { defineExtension, defineNode } from "@mcp-moira/extension-sdk";
const send = defineNode({ type: "corporate-messenger.send", async handler({ config, signal, services }) { const token = await services.secret("corporate-messenger.token"); if (!token) throw new Error("corporate-messenger.token не заполнен");
const response = await services.fetch("https://api.example.com/messages", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", }, body: JSON.stringify({ chat: config.chat, text: config.text }), signal, }); if (!response.ok) throw new Error(`сообщение не принято: ${response.status}`);
const body = (await response.json()) as { id: string }; services.log("сообщение отправлено", { chat: String(config.chat) }); return { messageId: body.id }; },});
export default defineExtension({ nodes: [send] });Манифест — источник истины для схем. Схему можно повторить в defineNode, но тогда runner требует
точного совпадения при первой загрузке кода дочерним процессом.
Обработчик получает:
| Значение | Контракт |
|---|---|
config | Конфигурация после глубокой подстановки шаблонов и проверки по configSchema |
input | Сопоставленный вход вызова, проверенный по inputSchema, если она объявлена |
executionId, nodeId | Идентификаторы вызова; другого состояния исполнения нет |
signal | Срабатывает при отмене или истечении срока |
services | Явные возможности, выданные манифестом |
Фонового доступа к базе, окружению, конфигурации или файловой системе Moira нет.
Обработчик канала коммуникации
Объявите транспорт через defineChannel и экспортируйте его в communicationChannels:
/* index.ts */ import { defineChannel, defineExtension } from "@mcp-moira/extension-sdk";
const notifications = defineChannel({ id: "corporate-messenger.notifications", async handler({ message, settings, signal, services }) { const token = await services.secret("corporate-messenger.token"); const chat = settings["corporate-messenger.default_chat"]; if (!token || typeof chat !== "string") throw new Error("канал не настроен");
const response = await services.fetch("https://api.example.com/messages", { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ chat, text: message.text }), signal, }); if (!response.ok) throw new Error(`сообщение не принято: ${response.status}`); },});
export default defineExtension({ communicationChannels: [notifications] });Обработчик получает переносимое сообщение, проверенные схемой несекретные настройки, сигнал отмены
и только службы fetch и secret. Службы журналов и артефактов у него нет. Если обработчик
повторяет configurationSchema в defineChannel, схема должна точно совпадать с манифестом. Текст
доступен всегда; объявляйте поддержку изображения или документа, только если обработчик принимает
необязательное бинарное вложение вместе с именем файла и MIME-типом. Одно универсальное вложение
ограничено 20 МиБ.
Подтверждение доверенной доставки
Для чувствительного сообщения недостаточно заявления в манифесте. Фактическая допустимость требует одновременного выполнения всех условий:
- установленный канал объявляет
capabilities.trustedDelivery: true; - администратор независимо подтвердил ID этого канала;
- персональная конфигурация пользователя корректна и включена;
- адаптер через runner находится в рабочем состоянии.
Администратор смотрит объявления через GET /api/admin/communication/trusted-channels и сохраняет
или отзывает независимое решение так:
PUT /api/admin/communication/trusted-channels/corporate-messenger.notificationsContent-Type: application/json
{ "approved": true }Подтверждение действует на всю установку, записывается в аудит, переживает перезапуск runner и не добавляет сетевых разрешений или доступа к секретам. Оно не превращает обычный канал в доверенный. Доставка PIN встроенной lock-ноды остаётся привязана к Telegram: каналы расширений не получают PIN через веерную отправку обычных уведомлений.
Службы и разрешения
services.log(message, fields)пишет структурную запись runner. Не включайте значения изsecret: журнал не ищет и не маскирует их.services.fetch(url, init)разрешает только точные хосты изpermissions.network. Перенаправления проверяются повторно, а при смене origin чувствительные заголовки удаляются.services.secret(alias)возвращает выданную настройку как текст илиnull, если она не заполнена.services.writeArtifact(name, content)требуетpermissions.artifacts: true. Артефакты возвращаются вместе с успешным результатом. Один артефакт ограничен 256 КиБ, весь вызов — 1 МиБ; превышение любого лимита роняет вызов.
Результаты, сбои и сроки
Успешный результат сохраняется под ID ноды. Поле messageId ноды send-message читается дальше как
{{send-message.messageId}}. Ноды расширений не объявляют глобальные записи.
Каждый сбой получает именованную диагностику. Если есть connections.error, workflow переходит по
этой связи:
| Вид | Значение |
|---|---|
invalid-input | Конфигурация или вход после подстановки не прошли схему |
handler-error | Обработчик бросил исключение или точка входа не загрузилась |
timeout | Вызов превысил срок ноды |
runner-unavailable | Runner недоступен или дочерний процесс умер |
invalid-output | Возвращённое значение не прошло outputSchema |
Без связи error Moira записывает диагностику и приостанавливает работающее исполнение на той же
ноде. Продолжение исполнения вызывает расширение повторно. Runner переиспользует контролируемый
процесс обработчика между вызовами и может выполнять в нём несколько вызовов параллельно; после
падения, превышения срока или отмены процесс заменяется. Не полагайтесь на локальное состояние
процесса. Обработчик должен быть повторно входимым и безопасным для остановки.
Нода расширения — один синхронный вызов с ограниченным сроком. Расширение не создаёт нативную фоновую задачу, не будит агента и не устанавливает callback. Длительную операцию выразите графом: запустите её одной нодой, сохраните идентификатор в результате, проверьте позже и организуйте цикл через condition-ноду. Не держите один обработчик в ожидании.
Универсальный редактор workflow
Браузер не загружает frontend-код расширения. Он получает живой каталог типов нод из Moira и строит
универсальную форму по configSchema. Поддерживаемые схемой примитивы, массивы, объекты, enum и
вложенные поля редактируются в ней; требования к представлению описывайте схемой, а не JavaScript.
Если настроенный runner недоступен, валидация и редактор сообщают о недоступном реестре расширений, а не превращают все пользовательские типы в неизвестные. Если реестр доступен, но типа в нём нет, Moira называет неустановленное расширение.
Референсный пример
examples/extensions/webhook-notify содержит полную исходящую action-ноду и обычный текстовый канал
коммуникации. Они используют общие объявленные персональные настройки эндпоинта, но имеют отдельные
точные сетевые разрешения и доступы к секрету. У ноды есть именованный сбой ограничения частоты и
результат messageId в её области; канал использует настроенного получателя по умолчанию и намеренно
не запрашивает доверенную доставку. Это не входящий webhook-триггер. Копируйте и адаптируйте этот
пример, а не служебные бандлы из tests/fixtures.
Команды установки и диагностики приведены в Self-hosting: включение расширений.