Перейти к содержимому

Написание расширения

Расширение добавляет в Moira типы нод в собственном пространстве имён, исходящие каналы коммуникации или оба вида вкладов. Собственные ноды используют тот же граф, что и встроенные: Moira проверяет конфигурацию, вызывает изолированный runner, сохраняет успешный результат под ID ноды и при сбое переходит по её связи error, если она есть. Без этой связи Moira записывает диагностику и приостанавливается на ноде для повторной попытки. Каналы коммуникации — это транспорты встроенной универсальной ноды user-notification; они не являются типами нод и не получают идентификаторы workflow или пользователя.

Код расширения не исполняется в процессах приложения Moira. Runner импортирует каждый бандл в отдельном дочернем процессе. Это изолирует падения и сроки выполнения, но не является песочницей для враждебного кода: администратор должен устанавливать только доверенные расширения.

Структура бандла

Каждый установленный каталог содержит манифест и точку входа:

my-extension/
├── moira-extension.json
└── index.ts

Runner сканирует каталоги первого уровня внутри 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.notifications
Content-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-unavailableRunner недоступен или дочерний процесс умер
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: включение расширений.