Skip to content

Справочник по манифесту ​

Каждый модуль объявляет module-manifest.json в своём корне. tv-sdk validate проверяет его Zod-схемой из @tv/extension-sdk/manifest — это авторитетная проверка, та же самая, что выполняют CI и реестр.

Проверка ​

bash
npx @tv/extension-sdk validate module-manifest.json
# выход 0 — валиден, 1 — невалиден, 2 — ошибка использования

manifest.schema.json для проверки в редакторе

В пакете также поставляется manifest.schema.json. Начиная с 1.1.0 он генерируется в режиме input — то есть описывает то, что вам разрешено писать, — согласуется с tv-sdk validate и его можно смело подключать в редакторе:

json
"$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json"

В версиях 1.0.x он выпускался в режиме output и помечал обязательным каждое поле со значением по умолчанию; он отклонял 30 из 31 модуля, работавших тогда. Если вы закреплены ниже 1.1.0 — не указывайте $schema.

Завязывайте CI на tv-sdk validate, а не на JSON Schema: валидатор — это контракт, который проверяет реестр, а схема генерируется из него.

Поля верхнего уровня ​

Обязательных полей ровно пять: id, name, version, minCoreVersion и category. У всего остального либо есть значение по умолчанию, либо оно действительно необязательно.

ПолеОбязательноОписание
idда@vendor/module-name — глобально уникальный. Используйте свою область, не @tv.
nameдаЧеловекочитаемое имя, отображаемое в консоли
versionдаSemver вашего модуля
minCoreVersionдаМинимальная версия tv-api, напр. >=2.0.0
categoryдаcore / operations / engagement / infrastructure / analytics / ai
sdkVersionпо умолчаниюВерсия SDK, под которую написан манифест. Генератор проставляет актуальную; в самой схеме по умолчанию стоит устаревшая 0.2.0, так что задайте её явно. Платформа отклоняет манифесты, нацеленные на более новый SDK, чем у неё.
buildingTypesпо умолчанию["all"] или конкретные слаги, напр. ["mall","office"] — см. ниже
capabilitiesпо умолчаниюprovides + requires — именованные контракты возможностей между модулями
permissionsпо умолчаниюДомены данных, которые вы читаете/пишете (см. ниже)
eventsпо умолчаниюМассивы publishes + subscribes
mcpToolsпо умолчаниюИнструменты Copilot/MCP, которые даёт ваш модуль. [], если их нет.
lifecycleпо умолчаниюhealthEndpoint, init, dependencies
nameLocalesнетname по языкам, например { "ru": "ТОиР" } (SDK 1.18.0+) — см. UI
descriptionнетОднострочное описание для каталога
maxCoreVersionнетВерхняя граница, если вы знаете, что выше ломаетесь
mcpEndpointнетURL, по которому платформа вызывает ваши MCP-инструменты, — см. ниже
uiесли есть UIмаршруты, навигация, виджеты дашборда
authorнетname, email, url

tv-sdk validate примет манифест без любого поля со значением по умолчанию — но объявляйте их явно. Это разница между «у меня нет событий» и «я забыл подумать про события», а ревьюер их не различит.

Неизвестные ключи отклоняются, а не игнорируются

Все объекты схемы строгие. Опечатка или выдуманное поле приводят к ошибке валидации, а не отбрасываются молча, — это то поведение, которое вам нужно, и самый частый сюрприз для новичков.

Разрешения ​

json
"permissions": [
  {
    "subject": "building.spaces",
    "actions": ["read"],
    "reason": "Выводит список помещений на главной странице модуля."
  }
]

Для валидатора reason необязателен, а на практике обязателен — он дословно показывается администратору, который одобряет установку.

Каталог ​

Субъекты и действия берутся из реестра разрешений tv-api и поставляются с SDK в файле permissions.snapshot.json. Полный список — каждый субъект, каждое действие и роли, которым оно выдано, — строится из этого же снимка на странице Каталог разрешений, поэтому он не может разойтись с тем, что платформа реально проверяет. Там же перечислены зарезервированные префиксы, доступные только модулям первой стороны.

Если не хотите покидать терминал — читайте прямо из пакета:

bash
cat node_modules/@tv/extension-sdk/permissions.snapshot.json | jq '.subjects[].subject'

Типы зданий ​

buildingTypes определяет, для каких зданий предлагается ваш модуль. Схема проверяет форму слага, а набор существующих слагов задаёт реестр building_types в tv-api: POST /api/v1/registry/modules/ingest отклонит манифест с несуществующим типом и перечислит допустимые значения.

Эта проверка появилась в SDK 1.10.0 не просто так: до неё опечатка спокойно проходила ingest и молча — и навсегда — ограничивала модуль пустым множеством зданий.

UI ​

json
"ui": {
  "remoteEntry": "./Shell",
  "routes": [{ "path": "/cafm", "requiresLicense": true, "exact": false }],
  "navigation": [
    { "label": "CAFM", "labelLocales": { "ru": "ТОиР" }, "icon": "Wrench", "path": "/cafm", "section": "operations", "order": 100, "roles": [] }
  ],
  "dashboardWidgets": [
    { "id": "open-work-orders", "title": "Open work orders", "remoteComponent": "./OpenWorkOrders", "defaultSize": "md", "defaultRoles": [] }
  ],
  "defaultLandingByRole": { "technician": "/cafm/my-work" }
}

routes — это места монтирования вашего федеративного Shell; exact (по умолчанию false) определяет, сопоставляется ли путь как префикс. navigation — то, что появляется на боковой панели Building OS; section — одно из operations, engagement, infrastructure, analytics, admin.

navigation[].roles — белый список слагов ролей; пустой или отсутствующий означает «видно всем, чья организация лицензирована на модуль». Это фильтр видимости, а не контроль доступа: доступ в любом случае проверяет сервер.

navigation[].labelLocales переводит подпись в боковой панели; ключ — тег языка (ru, pt-BR). Building OS показывает запись для языка пользователя, затем для его базового языка (pt для pt-BR), затем label. Модуль вне Building OS не может дополнить собственные переводы оболочки, поэтому переводы его подписи задаются здесь. Поле верхнего уровня nameLocales делает то же для name там, где оболочка называет модуль целиком, а не одну из его записей. Оба поля требуют @tv/extension-sdk 1.18.0 или новее: более ранние версии отклоняют их с Unrecognized key.

dashboardWidgets добавляют плитки на дашборд оболочки. id соответствует ^[a-z][a-z0-9-]*$, remoteComponent должен начинаться с ./ и быть отдельным экспортом федерации, а defaultSize — это sm (одна колонка), md (две, по умолчанию) или lg (во всю ширину).

defaultLandingByRole сопоставляет слаг роли с путём: когда пользователь с такой ролью открывает оболочку, / перенаправляет туда, а не на дашборд.

remoteEntry должен быть ./Shell — оболочка ищет ровно это имя. Проверяйте это в CI:

bash
npx @tv/extension-sdk check-exposes module-manifest.json --config=./vite.config.ts

Жизненный цикл ​

json
"lifecycle": {
  "healthEndpoint": "/health",
  "init": "on_demand",
  "dependencies": []
}

init: "on_demand" загружает модуль лениво, при первом обращении к его маршруту; on_boot запускает его вместе с оболочкой. В dependencies перечисляются идентификаторы модулей, которые должны стать HEALTHY до вашего старта, — держите список пустым, если вы действительно не можете работать без другого модуля.

Инструменты Copilot (MCP) ​

json
"mcpTools": [
  {
    "name": "cafm_list_work_orders",
    "description": "List work orders for a building, optionally filtered by status.",
    "permissions": ["cafm.workorders:read"],
    "parameters": {
      "type": "object",
      "properties": { "status": { "type": "string" } }
    }
  }
],
"mcpEndpoint": "http://cafm-backend.prod.svc.cluster.local/api/mcp"

name соответствует ^[a-z][a-z0-9_]*$; принятое соглашение — {moduleSlug}_{verb}_{noun}. Два инструмента не могут носить одно имя — каталог инструментов Copilot представляет собой одно плоское пространство имён, и валидатор отклонит дубликат.

description — это то, что читает модель, решая, вызывать ли ваш инструмент, так что пишите его для модели. parameters — произвольная JSON Schema, которая передаётся модели дословно и платформой не валидируется. permissions — это строки вида "<subject>:<action>"; они образуют слой авторизации поверх того, чем бы вызов ни аутентифицировался.

mcpEndpoint — поле, в котором ошибаются чаще всего ​

tv-api вызывает POST {mcpEndpoint}/tools/{toolName} с { arguments, context }, где context — это { userId, organizationId, buildingId, roles }, и ожидает 200 { result } либо 4xx/5xx { error: { code, message } }.

Как только ваш модуль развёрнут в кластере платформы, хостом должно быть имя развёрнутого Service в пространстве имён prod, без порта, и его нельзя выводить из слага модуля. Аудит в августе 2026 года нашёл неверную форму в 23 манифестах из 30: они рекламировали 63 инструмента, вызовы которых молча умирали на разрешении DNS. Не забудьте и про глобальный префикс: при setGlobalPrefix('api') контроллер @Controller('mcp/tools') отвечает по /api/mcp/tools/…, то есть эндпоинт заканчивается на /api/mcp.

Это правило описывает модуль, который уже развернула Tango Vision, — собственный модуль с первого дня, партнёрский модуль — с момента продвижения (см. «Партнёрские модули» ниже). До продвижения mcpEndpoint может указывать куда угодно, где вы сами обслуживаете POST /tools/:toolName, — этот адрес пока никто не вызывает. Полностью опустить mcpEndpoint тоже допустимо на любом этапе — тогда инструменты сводятся к структурированной заглушке «невозможно выполнить», а не к ошибке.

Аутентификация вашего MCP-эндпоинта ​

Каждый MCP-эндпоинт обязан быть защищён, и принимаются ровно два вида учётных данных. Неаутентифицированные запросы получают 401.

  1. HMAC-подпись — для вызовов между сервисами со стороны Copilot. Используйте McpSignatureGuard из @tv/extension-sdk/nestjs. Он проверяет X-TV-Platform-Timestamp (старше 5 минут — отказ) и X-TV-Platform-Signature, то есть HMAC-SHA256 от {timestamp}.{raw-body}, под секретом, который есть только у Tango Vision и у вашего модуля.
  2. JWT реалма — и только там, где действительно есть вызов, инициированный пользователем, — например фронтенд модуля вызывает MCP-инструмент соседнего модуля с сессией самого пользователя. Проверяйте его по Keycloak JWKS — только RS256, со сверкой и подписи, и издателя — и перезаписывайте MCP-context из проверенного токена. Присланной клиентом личности доверять нельзя никогда.

McpSignatureGuard отказывает каждому запросу в продакшене без секрета

Начиная с релиза @tv/extension-sdk, в котором это изменение появилось (версионировано как 1.17.0 в changelog SDK и впервые опубликовано в 1.18.0 2026-09-28; во всех последующих релизах оно присутствует), McpSignatureGuard в продакшене больше не пропускает неподписанный трафик. При NODE_ENV=production и незаданных ни TV_PLATFORM_SIGNING_SECRET, ни помодульном TV_PLATFORM_SIGNING_SECRET_<SLUG> guard отвечает на каждый запрос 401 MCP_SIGNING_SECRET_MISSING, а процесс продолжает работать — /health остаётся зелёным, отказывает только MCP-маршрут. Первый отказ однократно логируется на уровне error с именем переменной, которую нужно задать.

В любом другом NODE_ENV (включая не заданный) запрос без подписи всё ещё принимается с предупреждением — так же, как сам tv-api, когда у исполнителя нет ключа. При заданном ключе поведение в обеих ветках не меняется: что корректно настроенный модуль принимает и отвергает — идентично, поэтому для вызывающих с ключом это не изменение протокола.

Перед развёртыванием модуля на SDK 1.18.0 или новее в продакшен-кластере объявите ключ подписи по имени: TV_PLATFORM_SIGNING_SECRET_<SLUG> для собственного модуля (Tango Vision выдаёт его при продвижении партнёрского модуля; получить его раньше самостоятельно нельзя) или общий TV_PLATFORM_SIGNING_SECRET, если модуль использует ключ платформы. Без ключа ваш эндпоинт будет отказывать единственному вызывающему. Локальная разработка при незаданном NODE_ENV или NODE_ENV=development не затрагивается.

Собственные модули Tango Vision ​

Собственный модуль получает свой секрет подписи при развёртывании и вызывается Copilot с первого дня. Защитите POST /tools/:toolName через McpSignatureGuard, создав его со слагом вашего модуля в реестре:

ts
// mcp.controller.ts
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { McpSignatureGuard } from '@tv/extension-sdk/nestjs';

interface McpCallContext {
  userId: string;
  organizationId: string;
  buildingId?: string;
  roles: string[];
}

interface McpToolRequest {
  arguments: Record<string, unknown>;
  context: McpCallContext;
}

// 'cafm' — слаг этого модуля в реестре: та же строка, что tv-api присылает
// как `moduleSlug`, и суффикс TV_PLATFORM_SIGNING_SECRET_CAFM.
@Controller('mcp/tools')
@UseGuards(new McpSignatureGuard('cafm'))
export class McpController {
  @Post(':toolName')
  callTool(@Param('toolName') toolName: string, @Body() body: McpToolRequest) {
    switch (toolName) {
      case 'cafm_list_work_orders':
        return { result: this.listWorkOrders(body.arguments, body.context) };
      default:
        return { error: { code: 'UNKNOWN_TOOL', message: `No such tool: ${toolName}` } };
    }
  }

  private listWorkOrders(args: Record<string, unknown>, context: McpCallContext) {
    // ... найти заявки в рамках context.organizationId
    return [];
  }
}

Если у вашего модуля есть ещё и вызов, инициированный пользователем, принимайте JWT реалма в дополнение к подписи, а не вместо неё. SDK экспортирует для этого только McpSignatureGuard; JWT-половина — это обычный NestJS плюс библиотека JOSE, а не приватный код платформы:

ts
// realm-jwt.guard.ts — JWT-половина; сочетается с McpSignatureGuard выше
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

interface RequestWithMcpContext {
  headers: Record<string, string | string[] | undefined>;
  body: { context?: McpCallContext };
}

@Injectable()
export class RealmJwtGuard implements CanActivate {
  private readonly jwks = jwksClient({
    jwksUri: `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/certs`,
    cache: true,
    rateLimit: true,
  });

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<RequestWithMcpContext>();
    const header = request.headers['authorization'];
    const token = typeof header === 'string' ? header.replace(/^Bearer /, '') : undefined;
    if (!token) {
      throw new UnauthorizedException({
        code: 'MCP_MISSING_BEARER',
        message: 'Authorization: Bearer <realm JWT> is required.',
      });
    }

    const decoded = jwt.decode(token, { complete: true });
    const kid = decoded?.header.kid;
    if (!decoded || typeof kid !== 'string') {
      throw new UnauthorizedException({ code: 'MCP_BAD_TOKEN', message: 'Malformed JWT.' });
    }

    const signingKey = await this.jwks.getSigningKey(kid);
    let claims: jwt.JwtPayload;
    try {
      claims = jwt.verify(token, signingKey.getPublicKey(), {
        algorithms: ['RS256'],
        issuer: process.env.KEYCLOAK_ISSUER,
      }) as jwt.JwtPayload;
    } catch {
      throw new UnauthorizedException({ code: 'MCP_BAD_TOKEN', message: 'Signature or issuer mismatch.' });
    }

    const roles: string[] =
      claims.realm_access?.roles ?? (Array.isArray(claims.roles) ? claims.roles : []);

    // Присланной клиентом личности доверять нельзя: перезаписываем её
    // из проверенного токена.
    request.body.context = {
      userId: String(claims.sub),
      organizationId: String(claims.organization_id ?? ''),
      buildingId: typeof claims.building_id === 'string' ? claims.building_id : undefined,
      roles,
    };
    return true;
  }
}

Соедините оба guard'а: сначала пробуйте заголовок подписи, и только если его нет — проверяйте JWT:

ts
// mcp-access.guard.ts — допускает любой из двух видов учётных данных
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { McpSignatureGuard } from '@tv/extension-sdk/nestjs';
import { RealmJwtGuard } from './realm-jwt.guard';

@Injectable()
export class McpAccessGuard implements CanActivate {
  constructor(
    private readonly hmac: McpSignatureGuard,
    private readonly jwtGuard: RealmJwtGuard,
  ) {}

  canActivate(context: ExecutionContext): boolean | Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    if (request.headers['x-tv-platform-signature']) {
      return this.hmac.canActivate(context);
    }
    return this.jwtGuard.canActivate(context);
  }
}

Не изобретайте третью схему.

Партнёрские модули ​

Партнёрские модули — такие, как тот, что вы сейчас строите, — живут по другому графику.

Вы объявляете mcpTools в манифесте с самого начала: в том, как их писать и валидировать, нет никакой разницы с собственным модулем Tango Vision. Но Copilot не вызывает ваш эндпоинт, пока модуль не продвинут — речь о ручной проверке, описанной в разделе что можно и чего нельзя. Продвижение — это ещё и момент, когда Tango Vision выдаёт секрет подписи вашего модуля (TV_PLATFORM_SIGNING_SECRET_<SLUG>); получить или сгенерировать его раньше самостоятельно нельзя, так что ничего, построенное до продвижения, не заставит Copilot вызвать ваш инструмент раньше срока.

Что делать тем временем:

  • Проще всего — не указывать mcpEndpoint в манифесте до тех пор, пока продвижение не станет близким. mcpTools при этом всё равно объявляется и проходит валидацию; инструменты просто сведутся к заглушке «невозможно выполнить», описанной выше. Это безопасный вариант по умолчанию, пока вы ещё строите модуль, — и то, что мы рекомендуем.
  • Если хотите собрать и проверить эндпоинт заранее, защитите его через McpSignatureGuard точно так же, как в примере для собственных модулей выше, указав слаг своего модуля. Сегодня это компилируется и работает на опубликованном SDK. Сначала прочитайте предупреждение выше: без секрета — а он появится у вас только при продвижении — guard отказывает каждому запросу с 401 MCP_SIGNING_SECRET_MISSING в любом развёртывании, где NODE_ENV=production (а в остальных средах по-прежнему принимает запрос без подписи с предупреждением — так что локальная разработка при NODE_ENV=development или незаданном NODE_ENV продолжает работать). Не разворачивайте этот эндпоинт там, куда можно достучаться из публичного интернета, до продвижения: держите его на localhost либо на хосте своей песочницы — так же, как уже устроена остальная разработка вашего модуля.
  • JWT-вариант вам не понадобится до продвижения: пока ваш модуль не загружается ни в одну оболочку, куда входит пользователь, вызова, инициированного пользователем, попросту нет.

Сообщите своему контакту, когда модуль будет готов к проверке, — вас проведут через продвижение, включая выдачу секрета выше.

Полный пример ​

Запросите эталонный модуль tv-module-example — репозиторий закрытый, копия предоставляется по запросу (developers@tango.vision) — это полный проверенный манифест с mcpTools, mcpEndpoint, heartbeat и федерацией, подписанной HMAC. В каталоге модулей перечислены все модули, работающие сегодня.

Стабильность схемы ​

Схема манифеста следует semver SDK. Несовместимые изменения (новые обязательные поля, удалённые поля) повышают мажорную версию SDK. Дополняющие поля повышают минорную. См. политику стабильности.

Создано на платформе Tango Vision. Вопросы? developers@tango.vision