ИИ-агент может до написания виджета проверить, что эндпоинт существует и сверить точные имена полей (раньше выдумывал URL и shape — страницы не работали). Same-origin /api/*, GET only, доступен в любом режиме ключа; запрос идёт от пользователя-владельца ключа.
5756 lines
251 KiB
TypeScript
5756 lines
251 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||
import { z } from "zod";
|
||
import crypto from "crypto";
|
||
import { storage } from "./storage";
|
||
import { authService } from "./services/auth.service";
|
||
import { FORM_FIELD_TYPES, GLOBAL_FIELD_TYPES, TABLE_FIELD_TYPES } from "../shared/field-types";
|
||
import { normalizeFieldValueForStorage } from "./utils/normalize-field-value";
|
||
import { normalizeFileUrl, normalizeFileFieldUrls } from "./utils/upload";
|
||
import { parseUserFieldEntries } from "./utils/user-field-value";
|
||
import { runAutomationsByTrigger } from "./routes/automation.routes";
|
||
|
||
|
||
// In-process dedup for legacy-key admin notifications (reset on process restart)
|
||
const _legacyKeyNotifiedMcp = new Set<string>();
|
||
async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: string, label: string): Promise<void> {
|
||
const k = `${organizationId}:${keyPrefix}`;
|
||
if (_legacyKeyNotifiedMcp.has(k)) return;
|
||
_legacyKeyNotifiedMcp.add(k);
|
||
try {
|
||
const admins = await storage.getUsersByRole('admin', organizationId);
|
||
for (const admin of admins) {
|
||
await storage.createUserNotification({
|
||
userId: admin.id,
|
||
organizationId,
|
||
type: 'system',
|
||
title: 'Устаревший API-ключ',
|
||
message: `API-ключ "${label}" (${keyPrefix}…) использует устаревший алгоритм (SHA-256). Удалите его и создайте новый в Настройки → API-ключи.`,
|
||
isRead: false,
|
||
});
|
||
}
|
||
} catch {
|
||
_legacyKeyNotifiedMcp.delete(k);
|
||
}
|
||
}
|
||
import type { Request, Response } from "express";
|
||
import type { Task, ApiKeyScopes, OrganizationApiKey, SafeUser, Bot } from "@shared/schema";
|
||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||
import { createUploadTicket } from "./utils/upload-tickets";
|
||
import { trackFileOnDemand } from "./utils/file-tracking";
|
||
import beautify from "js-beautify";
|
||
import {
|
||
semanticSearch,
|
||
getEmbeddingCounts,
|
||
type EntityType,
|
||
} from "./services/embedding.service";
|
||
import { formatUserName } from "./utils/formatUserName";
|
||
import { buildDataTableTree } from "./utils/data-table-tree";
|
||
import { sendTaskMessage, SendTaskMessageError } from "./services/task-message.service";
|
||
import { tasksMinimalCache, formsCache } from "./utils/cache";
|
||
import { evaluateAutoTransitions } from "./utils/auto-transitions";
|
||
import { signAccessToken } from "./utils/jwt";
|
||
import { notifyTaskAssigned } from "./utils/notifyAssignee";
|
||
import { eventBus, publishNotificationSSE, buildSystemFieldValues, buildTableRowMap, formatFieldValueForTitle, resolveTaskFieldTitles, type FieldTitleContext } from "./routes/shared";
|
||
import { indexFormAsync, indexTaskAsync } from "./routes/task-helpers";
|
||
import { DocumentTemplateService } from "./documents/template.service";
|
||
import { DocumentGenerationService } from "./documents/generation.service";
|
||
import { DataResolutionService } from "./documents/data-resolution.service";
|
||
import { AssetService } from "./documents/asset.service";
|
||
import { isS3Enabled } from "./utils/s3";
|
||
import { generatePresignedToken } from "./utils/presigned-tokens";
|
||
import { db, pool } from "./db";
|
||
import { fileUploads, taskReminders } from "@shared/schema";
|
||
import { eq, and } from "drizzle-orm";
|
||
import { decrypt as decryptSecret } from "./crypto";
|
||
|
||
/** Format JS page code with consistent indentation before storing in DB. */
|
||
function formatPageCode(code: string): string {
|
||
try {
|
||
return beautify.js(code, {
|
||
indent_size: 2,
|
||
indent_char: " ",
|
||
max_preserve_newlines: 2,
|
||
preserve_newlines: true,
|
||
brace_style: "collapse",
|
||
end_with_newline: false,
|
||
wrap_line_length: 0,
|
||
space_after_anon_function: true,
|
||
});
|
||
} catch {
|
||
return code;
|
||
}
|
||
}
|
||
|
||
/** Validate JS page code for known bad patterns. Returns array of warning strings. */
|
||
function validatePageCode(code: string): string[] {
|
||
const warnings: string[] = [];
|
||
|
||
if (/ctx\.navigate\(['"`]\/tasks\//.test(code)) {
|
||
warnings.push("BUG: ctx.navigate('/tasks/...') causes 404 — route does not exist. Use ctx.navigate('/forms/<formId>/tasks/' + taskId).");
|
||
}
|
||
|
||
const antdComponents = ["ui.Tag", "ui.Row", "ui.Col", "ui.Statistic", "ui.Spin", "ui.Space", "ui.Typography", "ui.Divider", "ui.Grid", "ui.List", "ui.Avatar", "ui.Tooltip"];
|
||
for (const comp of antdComponents) {
|
||
if (code.includes(comp)) {
|
||
warnings.push(`BUG: "${comp}" is Ant Design and does NOT exist in ctx.libs.ui. Use shadcn components only. Call get_js_coding_reference for the list.`);
|
||
}
|
||
}
|
||
|
||
if (!/return\s+function\s+Widget/.test(code)) {
|
||
warnings.push("BUG: Code must end with 'return function Widget({ ctx }) { ... }' — page will not render without this.");
|
||
}
|
||
|
||
if (/\bres\.data\b/.test(code) || /\bdata\.data\b/.test(code)) {
|
||
warnings.push("POSSIBLE BUG: ctx.request() returns { success, tasks, users, forms, ... } — use res.tasks, res.users, not res.data.");
|
||
}
|
||
|
||
// Select family: SelectItem requires SelectTrigger + SelectContent + SelectValue
|
||
if (code.includes("SelectItem") && (!code.includes("SelectTrigger") || !code.includes("SelectContent") || !code.includes("SelectValue"))) {
|
||
warnings.push("BUG: SelectItem requires SelectTrigger, SelectContent, and SelectValue. All four must be destructured and used together. See get_js_coding_reference for correct Select usage.");
|
||
}
|
||
|
||
return warnings;
|
||
}
|
||
|
||
// ── Классификация MCP-инструментов по уровню доступа ────────────────────────
|
||
// READ_TOOLS — доступны во всех режимах (read, write, full): только чтение данных.
|
||
const READ_TOOLS: readonly string[] = [
|
||
'list_forms',
|
||
'get_form_fields',
|
||
'list_tasks',
|
||
'get_task',
|
||
'search_tasks',
|
||
'get_related_tasks',
|
||
'get_form_tabs',
|
||
'list_users',
|
||
'list_automations',
|
||
'get_automation',
|
||
'list_field_templates',
|
||
'list_tab_modules',
|
||
'get_tab_module',
|
||
'list_custom_pages',
|
||
'get_custom_page',
|
||
'get_js_coding_reference',
|
||
'validate_custom_page_code',
|
||
'api_get',
|
||
'semantic_search',
|
||
'get_organization_context',
|
||
'list_directories',
|
||
'get_directory',
|
||
'list_directory_rows',
|
||
'get_directory_row',
|
||
'get_directory_column_values',
|
||
'list_task_messages',
|
||
'get_task_assignees',
|
||
'list_document_templates',
|
||
'get_task_file',
|
||
'get_task_audit_log',
|
||
'list_task_reminders',
|
||
'list_notifications',
|
||
'get_inbox',
|
||
'aggregate_tasks',
|
||
'get_task_tree',
|
||
'get_user_field_values',
|
||
'dadata_suggest',
|
||
'get_api_guide',
|
||
];
|
||
|
||
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
|
||
const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||
'create_task',
|
||
'append_table_row',
|
||
'link_tasks',
|
||
'create_directory_row',
|
||
'bulk_create_directory_rows',
|
||
'send_task_message',
|
||
'generate_document',
|
||
'set_task_reminder',
|
||
'send_notification',
|
||
'mark_notifications_read',
|
||
'upload_task_file',
|
||
'upload_message_file',
|
||
'upload_directory_file',
|
||
'upload_table_row_file',
|
||
'create_upload_ticket',
|
||
];
|
||
|
||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||
// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full.
|
||
|
||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа
|
||
// + полная запись ключа (botId, createdBy, label) для атрибуции изменений.
|
||
export interface ResolvedApiKey {
|
||
organizationId: number;
|
||
scopes: ApiKeyScopes;
|
||
key: OrganizationApiKey;
|
||
}
|
||
|
||
// Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы
|
||
// (NULL в БД = полный доступ, legacy). Логика legacy-ключей (SHA-256) и touchApiKey сохранена.
|
||
async function resolveApiKey(req: Request): Promise<ResolvedApiKey | null> {
|
||
const rawKey =
|
||
(req.headers["x-api-key"] as string | undefined) ||
|
||
(req.headers["authorization"] as string | undefined)?.replace(/^Bearer\s+/i, "") ||
|
||
(req.query?.["apiKey"] as string | undefined) ||
|
||
(req.query?.["key"] as string | undefined);
|
||
if (!rawKey) return null;
|
||
const trimmed = rawKey.trim();
|
||
|
||
const apiKey = await storage.getApiKeyByHash(trimmed);
|
||
if (!apiKey) {
|
||
const legacyKey = await storage.getApiKeyByLegacyHash(trimmed);
|
||
if (legacyKey && legacyKey.isActive) {
|
||
console.warn(`[MCP] Legacy SHA-256 API key used (prefix: ${legacyKey.keyPrefix}). Regenerate this key.`);
|
||
_notifyAdminsLegacyKeyMcp(legacyKey.organizationId, legacyKey.keyPrefix, legacyKey.label).catch(() => {});
|
||
}
|
||
return null;
|
||
}
|
||
if (!apiKey.isActive) return null;
|
||
storage.touchApiKey(apiKey.id).catch(() => {});
|
||
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes), key: apiKey };
|
||
}
|
||
|
||
function taskToJson(t: Task) {
|
||
return {
|
||
id: t.id,
|
||
title: t.title,
|
||
formId: t.formId,
|
||
assignedTo: t.assignedTo,
|
||
currentStatusId: t.currentStatusId,
|
||
isCompleted: t.isCompleted,
|
||
dueDate: t.dueDate,
|
||
createdAt: t.createdAt,
|
||
updatedAt: t.updatedAt,
|
||
};
|
||
}
|
||
|
||
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyRecord: OrganizationApiKey | null): McpServer {
|
||
const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" });
|
||
|
||
// ── Фильтрация инструментов по режиму ключа (scopes.mode) ─────────────────
|
||
// read → только READ_TOOLS
|
||
// write → READ_TOOLS + WRITE_EXTRA_TOOLS
|
||
// full → все инструменты
|
||
const isToolAllowedByMode = (name: string): boolean => {
|
||
if (scopes.mode === 'full') return true;
|
||
if (scopes.mode === 'write') return READ_TOOLS.includes(name) || WRITE_EXTRA_TOOLS.includes(name);
|
||
return READ_TOOLS.includes(name);
|
||
};
|
||
|
||
// Обертка над server.registerTool: не регистрирует инструменты,
|
||
// недоступные по режиму ключа — клиент их просто не увидит.
|
||
// Тип typeof server.registerTool сохраняет вывод типов аргументов handler из inputSchema.
|
||
const register = ((name: string, meta: unknown, handler: unknown) => {
|
||
if (!isToolAllowedByMode(name)) return undefined;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
return (server.registerTool as any)(name, meta, handler);
|
||
}) as typeof server.registerTool;
|
||
|
||
// ── Объектный доступ по scopes.formIds ────────────────────────────────────
|
||
const isFormAllowed = (formId: number) => isFormAllowedByScopes(scopes, formId);
|
||
const formDenied = (formId: number) => ({
|
||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }],
|
||
isError: true,
|
||
});
|
||
|
||
// ── Актор изменений по API-ключу ───────────────────────────────────────────
|
||
// user — владелец ключа (fallback: первый админ) для notNull FK-колонок;
|
||
// bot — бот ключа (если привязан); displayName — имя для аудита
|
||
// (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек).
|
||
interface McpActor {
|
||
user: SafeUser;
|
||
bot: Bot | null;
|
||
displayName: string;
|
||
}
|
||
let actorPromise: Promise<McpActor> | null = null;
|
||
const getActor = (): Promise<McpActor> => {
|
||
if (!actorPromise) {
|
||
actorPromise = (async () => {
|
||
let user: SafeUser | null | undefined = apiKeyRecord?.createdBy
|
||
? await storage.getUser(apiKeyRecord.createdBy).catch(() => null)
|
||
: null;
|
||
if (!user || user.organizationId !== organizationId) {
|
||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
user = orgUsers.find((u) => u.appRole === 'admin') ?? orgUsers[0] ?? null;
|
||
}
|
||
if (!user) throw new Error('В организации нет пользователей');
|
||
const bot = apiKeyRecord?.botId
|
||
? await storage.getBot(apiKeyRecord.botId, organizationId).catch(() => null) ?? null
|
||
: null;
|
||
const displayName = bot?.name
|
||
?? (apiKeyRecord ? `Ключ «${apiKeyRecord.label}»` : null)
|
||
?? (`${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || 'MCP');
|
||
return { user, bot, displayName };
|
||
})();
|
||
}
|
||
return actorPromise;
|
||
};
|
||
|
||
// Поля аудит-записи от актора: для бота changedBy=null и botId выставлен,
|
||
// канал фиксируется в metadata.source='mcp'.
|
||
const actorAudit = (actor: McpActor) => ({
|
||
changedBy: actor.bot ? null : actor.user.id,
|
||
changedByName: actor.displayName,
|
||
botId: actor.bot?.id ?? null,
|
||
});
|
||
|
||
// Валидация fileUrl для привязки уже загруженного файла.
|
||
// Загрузка бинарных данных через MCP НЕ поддерживается: файл сначала
|
||
// загружается через REST POST /api/upload (см. get_api_guide), сюда передаётся только URL.
|
||
// Абсолютные self-URL (https://iistwin.ru/api/files/<key>) принимаются
|
||
// и нормализуются к относительному виду перед сохранением.
|
||
const resolveUploadSource = (args: {
|
||
fileName: string;
|
||
fileUrl: string;
|
||
fileSize?: number;
|
||
mimeType?: string;
|
||
}):
|
||
| { error: string }
|
||
| { file: { key: string; url: string; name: string; size: number; mimeType: string } } => {
|
||
const url = normalizeFileUrl(args.fileUrl);
|
||
if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) {
|
||
return { error: 'fileUrl должен указывать на файл этого сервера (/api/files/... или /uploads/..., в т.ч. абсолютный self-URL) — файл сначала загружается через REST POST /api/upload или upload-тикет' };
|
||
}
|
||
return {
|
||
file: {
|
||
key: url.replace(/^\/api\/files\/|^\/uploads\//, ''),
|
||
url,
|
||
name: args.fileName,
|
||
size: args.fileSize ?? 0,
|
||
mimeType: args.mimeType ?? 'application/octet-stream',
|
||
},
|
||
};
|
||
};
|
||
|
||
// ── Объектный доступ по scopes.tableIds (справочники) ─────────────────────
|
||
const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId);
|
||
const tableDenied = (tableId: number) => ({
|
||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к справочнику ${tableId} запрещён правами API-ключа` }) }],
|
||
isError: true,
|
||
});
|
||
|
||
// Ошибка инструмента справочников в едином формате { error }
|
||
const directoryError = (message: string) => ({
|
||
content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }],
|
||
isError: true,
|
||
});
|
||
|
||
// Универсальная ошибка MCP-инструмента в формате { error } (русский текст)
|
||
const mcpError = directoryError;
|
||
|
||
// Нормализация values строки справочника до длины массива columns:
|
||
// лишние значения обрезаются, недостающие дополняются пустыми строками
|
||
// (как normalizeValues в server/routes/data-tables-sync.routes.ts).
|
||
const normalizeRowValues = (values: (string | null | undefined)[] | null | undefined, columnCount: number): string[] => {
|
||
const arr = values ?? [];
|
||
const result: string[] = [];
|
||
for (let i = 0; i < columnCount; i++) {
|
||
const val = arr[i];
|
||
result.push(val === null || val === undefined ? '' : String(val).trim());
|
||
}
|
||
return result;
|
||
};
|
||
|
||
// list_forms
|
||
register(
|
||
"list_forms",
|
||
{
|
||
title: "List Forms",
|
||
description: "List all workflow forms in the organization",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const all = await storage.getFormsByOrganization(organizationId);
|
||
// Фильтруем выдачу по scopes.formIds (null = все формы)
|
||
const allowed = all.filter((f) => isFormAllowed(f.id));
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
allowed.map((f) => ({ id: f.id, name: f.name, description: f.description, createdAt: f.createdAt })),
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_form_fields
|
||
register(
|
||
"get_form_fields",
|
||
{
|
||
title: "Get Form Fields",
|
||
description: "Get all fields and statuses defined in a specific form",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The numeric ID of the form"),
|
||
},
|
||
},
|
||
async ({ form_id }) => {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
const [fields, statuses] = await Promise.all([
|
||
storage.getFormFields(form_id, organizationId),
|
||
storage.getFormStatuses(form_id, organizationId),
|
||
]);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{
|
||
form: { id: form.id, name: form.name },
|
||
statuses: statuses.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
color: s.color,
|
||
isInitial: s.isInitial,
|
||
isFinal: s.isFinal,
|
||
})),
|
||
fields: fields.map((f) => ({
|
||
id: f.id,
|
||
name: f.name,
|
||
type: f.type,
|
||
isRequired: f.isRequired,
|
||
options: f.options,
|
||
})),
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// list_tasks
|
||
register(
|
||
"list_tasks",
|
||
{
|
||
title: "List Tasks",
|
||
description: "List tasks filtered by form, status, or assignee",
|
||
inputSchema: {
|
||
form_id: z.number().int().optional().describe("Filter by form ID (optional)"),
|
||
status_id: z.number().int().optional().describe("Filter by status ID (optional)"),
|
||
assigned_to: z.number().int().optional().describe("Filter by assigned user ID (optional)"),
|
||
limit: z.number().int().min(1).max(200).optional().describe("Max results (default 50)"),
|
||
},
|
||
},
|
||
async ({ form_id, status_id, assigned_to, limit }) => {
|
||
// form_id передан явно — проверяем доступ к форме
|
||
if (form_id && !isFormAllowed(form_id)) return formDenied(form_id);
|
||
let taskList: Task[];
|
||
if (form_id) {
|
||
taskList = await storage.getTasksByForm(form_id, organizationId);
|
||
if (status_id !== undefined) taskList = taskList.filter((t) => t.currentStatusId === status_id);
|
||
if (assigned_to !== undefined) taskList = taskList.filter((t) => t.assignedTo === assigned_to);
|
||
} else {
|
||
taskList = (await storage.getTasksByOrganization(organizationId, {
|
||
limit: limit ?? 50,
|
||
statusId: status_id,
|
||
assignedTo: assigned_to,
|
||
minimal: false,
|
||
})) as Task[];
|
||
// Без form_id — фильтруем выдачу по разрешённым формам
|
||
taskList = taskList.filter((t) => isFormAllowed(t.formId));
|
||
}
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(taskList.slice(0, limit ?? 50).map(taskToJson), null, 2),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_task
|
||
register(
|
||
"get_task",
|
||
{
|
||
title: "Get Task",
|
||
description: "Get detailed information about a task including its field values",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The numeric ID of the task"),
|
||
},
|
||
},
|
||
async ({ task_id }) => {
|
||
const detail = await storage.getTaskDetail(task_id, organizationId);
|
||
if (!detail) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true };
|
||
const { task, form, fields, statuses, fieldValues, subtasks } = detail;
|
||
// Проверка доступа к форме задачи
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
const fieldMap = new Map(fields.map((f) => [f.id, f]));
|
||
const statusMap = new Map(statuses.map((s) => [s.id, s.name]));
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{
|
||
id: task.id,
|
||
title: task.title,
|
||
formId: task.formId,
|
||
formName: form.name,
|
||
assignedTo: task.assignedTo,
|
||
currentStatusId: task.currentStatusId,
|
||
currentStatusName: statusMap.get(task.currentStatusId) ?? null,
|
||
isCompleted: task.isCompleted,
|
||
dueDate: task.dueDate,
|
||
description: task.description,
|
||
createdAt: task.createdAt,
|
||
updatedAt: task.updatedAt,
|
||
fieldValues: fieldValues.map((fv) => ({
|
||
fieldId: fv.fieldId,
|
||
fieldName: fieldMap.get(fv.fieldId)?.name ?? null,
|
||
value: fv.value,
|
||
})),
|
||
subtaskCount: subtasks.length,
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_task
|
||
register(
|
||
"create_task",
|
||
{
|
||
title: "Create Task",
|
||
description: "Create a new task in a form",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The ID of the form to create the task in"),
|
||
title: z.string().min(1).describe("The title of the task"),
|
||
assigned_to: z.number().int().optional().describe("User ID to assign the task to (optional)"),
|
||
status_id: z.number().int().optional().describe("Status ID (optional, uses initial status if omitted)"),
|
||
description: z.string().optional().describe("Task description (optional)"),
|
||
due_date: z.string().optional().describe("Due date in YYYY-MM-DD format (optional)"),
|
||
field_values: z
|
||
.record(z.string(), z.unknown())
|
||
.optional()
|
||
.describe('Custom field values as object with field IDs as keys, e.g. {"123": "value"}'),
|
||
},
|
||
},
|
||
async ({ form_id, title, assigned_to, status_id, description, due_date, field_values }) => {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
|
||
const statuses = await storage.getFormStatuses(form_id, organizationId);
|
||
|
||
// Validate status_id belongs to this form
|
||
let resolvedStatusId: number;
|
||
if (status_id !== undefined) {
|
||
const validStatus = statuses.find((s) => s.id === status_id);
|
||
if (!validStatus) {
|
||
return { content: [{ type: "text" as const, text: `Status ${status_id} not found in this form` }], isError: true };
|
||
}
|
||
resolvedStatusId = validStatus.id;
|
||
} else {
|
||
const initialStatus = statuses.find((s) => s.isInitial) ?? statuses[0];
|
||
if (!initialStatus) {
|
||
return { content: [{ type: "text" as const, text: "Form has no statuses configured" }], isError: true };
|
||
}
|
||
resolvedStatusId = initialStatus.id;
|
||
}
|
||
|
||
// Validate assigned_to belongs to this organization
|
||
if (assigned_to !== undefined) {
|
||
const orgUser = await storage.getUser(assigned_to);
|
||
if (!orgUser || orgUser.organizationId !== organizationId) {
|
||
return { content: [{ type: "text" as const, text: `User ${assigned_to} not found in this organization` }], isError: true };
|
||
}
|
||
}
|
||
|
||
const actor = await getActor();
|
||
|
||
let parsedDueDate: Date | null = null;
|
||
if (due_date) {
|
||
const d = new Date(due_date);
|
||
if (!isNaN(d.getTime())) parsedDueDate = d;
|
||
}
|
||
|
||
const resolvedStatus = statuses.find((s) => s.id === resolvedStatusId);
|
||
const isFinalStatus = resolvedStatus?.isFinal ?? false;
|
||
|
||
const task = await storage.createTask({
|
||
title,
|
||
formId: form_id,
|
||
organizationId,
|
||
createdBy: actor.user.id,
|
||
assignedTo: assigned_to ?? null,
|
||
currentStatusId: resolvedStatusId,
|
||
description: description ?? null,
|
||
dueDate: parsedDueDate,
|
||
completedAt: isFinalStatus ? new Date() : null,
|
||
isCompleted: isFinalStatus,
|
||
parentTaskId: null,
|
||
});
|
||
|
||
// Assignee в task_assignees (модель исполнителей): assignedTo сам по себе строку не создаёт
|
||
if (assigned_to !== undefined && assigned_to !== null) {
|
||
await storage.addTaskAssignee(task.id, assigned_to, organizationId);
|
||
}
|
||
|
||
storage.addTaskAuditLog({
|
||
taskId: task.id,
|
||
organizationId,
|
||
action: "task.created",
|
||
...actorAudit(actor),
|
||
metadata: { title: task.title, source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error("Audit log error (MCP create_task):", e); });
|
||
|
||
if (field_values && typeof field_values === "object") {
|
||
const formFieldsList = await storage.getFormFields(form_id, organizationId);
|
||
const fieldMap = new Map(formFieldsList.map((f) => [f.id, f]));
|
||
for (const [key, val] of Object.entries(field_values)) {
|
||
const fieldId = parseInt(key);
|
||
const field = fieldMap.get(fieldId);
|
||
if (!isNaN(fieldId) && field && val !== undefined && val !== "") {
|
||
await storage.createTaskFieldValue({ taskId: task.id, fieldId, formId: form_id, value: normalizeFieldValueForStorage(val, field.type) });
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, task: { id: task.id, title: task.title, formId: task.formId } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_task_status
|
||
register(
|
||
"update_task_status",
|
||
{
|
||
title: "Update Task Status",
|
||
description: "Change the status of a task",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The task ID"),
|
||
status_id: z.number().int().describe("The new status ID (must belong to the task's form)"),
|
||
},
|
||
},
|
||
async ({ task_id, status_id }) => {
|
||
const task = await storage.getTask(task_id, organizationId);
|
||
if (!task) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true };
|
||
// Проверка доступа к форме задачи
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
// Validate status belongs to the task's form
|
||
const statuses = await storage.getFormStatuses(task.formId, organizationId);
|
||
const validStatus = statuses.find((s) => s.id === status_id);
|
||
if (!validStatus) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Status ${status_id} does not belong to this task's form` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
|
||
const isFinalStatus = validStatus.isFinal;
|
||
const updated = await storage.updateTask(task_id, organizationId, {
|
||
currentStatusId: status_id,
|
||
isCompleted: isFinalStatus,
|
||
completedAt: isFinalStatus ? (task.completedAt ?? new Date()) : null,
|
||
});
|
||
|
||
// Аудит смены статуса с актором ключа (как REST: action 'status.changed')
|
||
const oldStatus = statuses.find((s) => s.id === task.currentStatusId);
|
||
if (task.currentStatusId !== status_id) {
|
||
const actor = await getActor();
|
||
storage.addTaskAuditLog({
|
||
taskId: task_id,
|
||
organizationId,
|
||
action: 'status.changed',
|
||
fieldName: 'Статус',
|
||
oldValue: oldStatus?.name ?? String(task.currentStatusId),
|
||
newValue: validStatus.name,
|
||
...actorAudit(actor),
|
||
metadata: { source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task_status):", e); });
|
||
|
||
// Триггер автоматизаций task.status_changed (как в executeTaskTransition), fire-and-forget
|
||
runAutomationsByTrigger(organizationId, 'task.status_changed', {
|
||
formId: task.formId,
|
||
taskId: task_id,
|
||
oldStatusId: task.currentStatusId,
|
||
newStatusId: status_id,
|
||
task: updated,
|
||
userId: actor.user.id,
|
||
}).catch((e: unknown) => { console.error('Automation trigger error (MCP update_task_status):', e); });
|
||
}
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, task: { id: updated.id, currentStatusId: updated.currentStatusId, statusName: validStatus.name, isCompleted: updated.isCompleted, completedAt: updated.completedAt } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_task
|
||
register(
|
||
"update_task",
|
||
{
|
||
title: "Update Task",
|
||
description: "Update task properties (title, description, assignee, due date, completion)",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The task ID"),
|
||
title: z.string().optional().describe("New title"),
|
||
description: z.string().optional().describe("New description"),
|
||
assigned_to: z.number().int().nullable().optional().describe("User ID to assign to, or null to unassign"),
|
||
due_date: z.string().nullable().optional().describe("Due date YYYY-MM-DD, or null to clear"),
|
||
is_completed: z.boolean().optional().describe("Mark task as completed/active"),
|
||
},
|
||
},
|
||
async ({ task_id, title, description, assigned_to, due_date, is_completed }) => {
|
||
const task = await storage.getTask(task_id, organizationId);
|
||
if (!task) return { content: [{ type: "text" as const, text: "Task not found" }], isError: true };
|
||
// Проверка доступа к форме задачи
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
// Validate assigned_to belongs to this organization
|
||
if (assigned_to !== undefined && assigned_to !== null) {
|
||
const orgUser = await storage.getUser(assigned_to);
|
||
if (!orgUser || orgUser.organizationId !== organizationId) {
|
||
return { content: [{ type: "text" as const, text: `User ${assigned_to} not found in this organization` }], isError: true };
|
||
}
|
||
}
|
||
|
||
const updates: Partial<Pick<Task, "title" | "description" | "assignedTo" | "dueDate" | "isCompleted" | "completedAt">> = {};
|
||
if (title !== undefined) updates.title = title;
|
||
if (description !== undefined) updates.description = description;
|
||
if (assigned_to !== undefined) updates.assignedTo = assigned_to;
|
||
if (is_completed !== undefined) {
|
||
updates.isCompleted = is_completed;
|
||
updates.completedAt = is_completed ? (task.completedAt ?? new Date()) : null;
|
||
}
|
||
if (due_date !== undefined) {
|
||
if (due_date === null) {
|
||
updates.dueDate = null;
|
||
} else {
|
||
const d = new Date(due_date);
|
||
if (!isNaN(d.getTime())) updates.dueDate = d;
|
||
}
|
||
}
|
||
|
||
const updated = await storage.updateTask(task_id, organizationId, updates);
|
||
|
||
// Аудит изменённых полей с актором ключа (как REST PUT /api/tasks/:id)
|
||
{
|
||
const actor = await getActor();
|
||
const resolveUserName = async (userId: number | null | undefined): Promise<string | null> => {
|
||
if (!userId) return null;
|
||
const u = await storage.getUser(userId).catch(() => null);
|
||
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(userId);
|
||
};
|
||
const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [
|
||
{ key: 'title', label: 'Заголовок' },
|
||
{ key: 'description', label: 'Описание' },
|
||
{ key: 'assignedTo', label: 'Исполнитель' },
|
||
{ key: 'dueDate', label: 'Срок' },
|
||
];
|
||
for (const { key, label } of trackedFields) {
|
||
if (!(key in updates)) continue;
|
||
const oldVal = task[key];
|
||
const newVal = updates[key];
|
||
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
|
||
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
|
||
if (oldStr === newStr) continue;
|
||
let auditOldValue: string | null = oldVal === null || oldVal === undefined ? null : String(oldVal);
|
||
let auditNewValue: string | null = newVal === null || newVal === undefined ? null : String(newVal);
|
||
if (key === 'assignedTo') {
|
||
[auditOldValue, auditNewValue] = await Promise.all([
|
||
resolveUserName(oldVal as number | null),
|
||
resolveUserName(newVal as number | null),
|
||
]);
|
||
}
|
||
storage.addTaskAuditLog({
|
||
taskId: task_id,
|
||
organizationId,
|
||
action: 'task.updated',
|
||
fieldName: label,
|
||
oldValue: auditOldValue,
|
||
newValue: auditNewValue,
|
||
...actorAudit(actor),
|
||
metadata: { source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task):", e); });
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, task: { id: updated.id, title: updated.title, isCompleted: updated.isCompleted } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_task_fields — upsert кастомных полей задачи (merge, как REST POST /api/tasks/:id/field-values).
|
||
// Full-only: намеренно НЕ входит в READ_TOOLS/WRITE_EXTRA_TOOLS (редактирование = полный доступ).
|
||
register(
|
||
"update_task_fields",
|
||
{
|
||
title: "Update Task Custom Fields",
|
||
description:
|
||
"Update custom field values of a task (merge/upsert: only the provided fields are changed). " +
|
||
"Value format depends on the field type (same as REST/клиент): " +
|
||
"text/textarea/rich-text/select/radio-group — string; number — number; date/datetime — 'YYYY-MM-DD' or ISO string; " +
|
||
"checkbox/toggle — boolean; user — user ID (number); table — directory row ID; task — related task ID; " +
|
||
"file — array of {url, name, size} (replaces the whole array; use upload_task_file to append); " +
|
||
"multiselect/checklist — array; contact/company/geo — object. " +
|
||
"Pass null/'' as value to clear the field. Use get_form_fields to discover field IDs and types.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
fieldValues: z.array(z.object({
|
||
fieldId: z.number().int().describe("The numeric ID of the form field"),
|
||
value: z.unknown().describe("New value in the format of the field type (null/'' clears the field)"),
|
||
})).min(1).describe("Fields to update (merge: other fields stay untouched)"),
|
||
},
|
||
},
|
||
async ({ taskId, fieldValues }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const formFields = await storage.getFormFields(task.formId, organizationId);
|
||
const fieldMap = new Map(formFields.map((f) => [f.id, f]));
|
||
|
||
// Все поля должны существовать в форме задачи — иначе ошибка с перечнем доступных
|
||
const missing = fieldValues.filter((fv) => !fieldMap.has(fv.fieldId));
|
||
if (missing.length > 0) {
|
||
const available = formFields.map((f) => `${f.id} (${f.name}, ${f.type})`).join(', ') || '(нет полей)';
|
||
return mcpError(
|
||
`Поля не найдены в форме ${task.formId}: ${missing.map((m) => m.fieldId).join(', ')}. Доступные: ${available}`
|
||
);
|
||
}
|
||
|
||
try {
|
||
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||
const oldValueMap = new Map(existingValues.map((v) => [v.fieldId, v.value]));
|
||
|
||
// Лимиты file-полей (как в REST PATCH): значение заменяет весь массив
|
||
for (const fv of fieldValues) {
|
||
const field = fieldMap.get(fv.fieldId)!;
|
||
if (field.type !== 'file' || fv.value == null) continue;
|
||
const newFiles = Array.isArray(fv.value) ? fv.value : [];
|
||
if (field.maxFileCount != null && newFiles.length > field.maxFileCount) {
|
||
return mcpError(`Поле ${fv.fieldId}: превышено максимальное количество файлов (${field.maxFileCount})`);
|
||
}
|
||
if (field.maxFileSizeMB != null) {
|
||
const totalBytes = newFiles.reduce((sum: number, f) => sum + (Number((f as Record<string, unknown>).size) || 0), 0);
|
||
if (totalBytes > field.maxFileSizeMB * 1024 * 1024) {
|
||
return mcpError(`Поле ${fv.fieldId}: суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Merge/upsert (как REST POST без replace=true):
|
||
// null/undefined/'' → удаление значения; иначе update или create
|
||
const updated: Array<{ fieldId: number; name: string; value: unknown }> = [];
|
||
for (const fv of fieldValues) {
|
||
const field = fieldMap.get(fv.fieldId)!;
|
||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||
const normalizedValueRaw = normalizeFieldValueForStorage(fv.value, field.type);
|
||
const normalizedValue = field.type === 'file' ? normalizeFileFieldUrls(normalizedValueRaw) : normalizedValueRaw;
|
||
const hasExisting = oldValueMap.has(fv.fieldId);
|
||
|
||
// Поле типа task: чистим старую связь перед записью новой
|
||
if (field.type === 'task' && hasExisting) {
|
||
await storage.deleteTaskRelationByField(fv.fieldId, taskId, organizationId);
|
||
}
|
||
|
||
if (normalizedValue === null || normalizedValue === undefined || normalizedValue === '') {
|
||
if (hasExisting) {
|
||
await storage.deleteTaskFieldValue(taskId, fv.fieldId, organizationId);
|
||
}
|
||
updated.push({ fieldId: fv.fieldId, name: field.name, value: null });
|
||
} else {
|
||
if (hasExisting) {
|
||
await storage.updateTaskFieldValue(taskId, fv.fieldId, organizationId, { value: normalizedValue });
|
||
} else {
|
||
await storage.createTaskFieldValue({ taskId, fieldId: fv.fieldId, formId: task.formId, value: normalizedValue });
|
||
}
|
||
updated.push({ fieldId: fv.fieldId, name: field.name, value: normalizedValue });
|
||
}
|
||
|
||
// Поле типа task: создаём связь parent/child (как REST POST)
|
||
if (field.type === 'task' && field.taskRelationType && fv.value) {
|
||
const selectedTaskId = parseInt(String(fv.value));
|
||
if (!isNaN(selectedTaskId)) {
|
||
if (field.taskRelationType === 'parent') {
|
||
await storage.upsertTaskRelation({ parentTaskId: selectedTaskId, childTaskId: taskId, fieldId: fv.fieldId, organizationId });
|
||
} else if (field.taskRelationType === 'child') {
|
||
await storage.upsertTaskRelation({ parentTaskId: taskId, childTaskId: selectedTaskId, fieldId: fv.fieldId, organizationId });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Аудит field.changed только для реально изменённых значений,
|
||
// с display-текстом (как REST POST: formatFieldValueForTitle + карты users/roles/task/table)
|
||
const changedEntries = fieldValues
|
||
.map((fv) => {
|
||
const field = fieldMap.get(fv.fieldId)!;
|
||
const oldVal = oldValueMap.has(fv.fieldId) ? oldValueMap.get(fv.fieldId) : undefined;
|
||
const newValRaw = normalizeFieldValueForStorage(fv.value, field.type);
|
||
const newVal = field.type === 'file' ? normalizeFileFieldUrls(newValRaw) : newValRaw;
|
||
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
|
||
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
|
||
return oldStr === newStr ? null : { field, oldVal, newVal };
|
||
})
|
||
.filter((e): e is NonNullable<typeof e> => e !== null);
|
||
|
||
const actor = await getActor();
|
||
const allValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||
const valByFieldId = new Map(allValues.map((v) => [v.fieldId, v.value]));
|
||
let titleCtx: FieldTitleContext | null = null;
|
||
if (changedEntries.length > 0) {
|
||
const [auditUsers, auditRoles] = await Promise.all([
|
||
storage.getUsersByOrganization(organizationId),
|
||
storage.getRoles(organizationId),
|
||
]);
|
||
titleCtx = {
|
||
usersMap: new Map(auditUsers.map((u) => [u.id, u])),
|
||
rolesMap: new Map(auditRoles.map((r) => [r.id, r])),
|
||
taskTitleMap: await resolveTaskFieldTitles(valByFieldId, formFields, organizationId),
|
||
tableRowMap: await buildTableRowMap(formFields, organizationId),
|
||
};
|
||
for (const { field, oldVal, newVal } of changedEntries) {
|
||
const displayOld = formatFieldValueForTitle(oldVal, field.type, titleCtx, field.options);
|
||
const displayNew = formatFieldValueForTitle(newVal, field.type, titleCtx, field.options);
|
||
storage.addTaskAuditLog({
|
||
taskId,
|
||
organizationId,
|
||
action: 'field.changed',
|
||
fieldId: field.id,
|
||
fieldName: field.name,
|
||
oldValue: oldVal ?? null,
|
||
newValue: newVal ?? null,
|
||
...actorAudit(actor),
|
||
metadata: {
|
||
displayOldValue: displayOld || null,
|
||
displayNewValue: displayNew || null,
|
||
source: 'mcp',
|
||
},
|
||
}).catch((e: unknown) => { console.error('Audit log error (MCP update_task_fields):', e); });
|
||
}
|
||
}
|
||
|
||
// Sync task_assignees из user-полей, чтобы задача попадала в inbox (как REST POST — только добавление).
|
||
// Мультивыбор: массив ["user:5", "role:2"]; role:<id> → все члены роли.
|
||
for (const fv of fieldValues) {
|
||
const field = fieldMap.get(fv.fieldId)!;
|
||
if (field.type !== 'user') continue;
|
||
for (const entry of parseUserFieldEntries(fv.value)) {
|
||
if (entry.startsWith('role:')) {
|
||
const roleId = Number(entry.replace('role:', ''));
|
||
if (isNaN(roleId)) continue;
|
||
const members = await storage.getRoleMembersByRole(roleId).catch(() => []);
|
||
for (const m of members) {
|
||
if (m.userId) {
|
||
await storage.addTaskAssignee(taskId, m.userId, organizationId).catch(() => {});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
const userId = Number(entry.replace(/^user:/, ''));
|
||
if (!isNaN(userId) && userId > 0) {
|
||
await storage.addTaskAssignee(taskId, userId, organizationId).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Пересчёт заголовка по titleTemplate формы (как REST POST)
|
||
const taskForm = await storage.getForm(task.formId, organizationId);
|
||
if (taskForm?.titleTemplate) {
|
||
if (!titleCtx) {
|
||
const [tUsers, tRoles] = await Promise.all([
|
||
storage.getUsersByOrganization(organizationId),
|
||
storage.getRoles(organizationId),
|
||
]);
|
||
titleCtx = {
|
||
usersMap: new Map(tUsers.map((u) => [u.id, u])),
|
||
rolesMap: new Map(tRoles.map((r) => [r.id, r])),
|
||
taskTitleMap: await resolveTaskFieldTitles(valByFieldId, formFields, organizationId),
|
||
tableRowMap: await buildTableRowMap(formFields, organizationId),
|
||
};
|
||
}
|
||
const statuses = await storage.getFormStatuses(task.formId, organizationId);
|
||
const sysValues = await buildSystemFieldValues(task, organizationId, { statuses, usersMap: titleCtx.usersMap });
|
||
const fieldCodeMap = new Map(formFields.map((f) => [f.code, f]));
|
||
const computedTitle = taskForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => {
|
||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||
const f = fieldCodeMap.get(code);
|
||
if (!f) return '';
|
||
const val = valByFieldId.get(f.id);
|
||
if (val === null || val === undefined) return '';
|
||
if (val === '__auto_prolongation__' && (f.type === 'date' || f.type === 'datetime')) {
|
||
return f.autoProlongationLabel || 'Автопролонгация';
|
||
}
|
||
return formatFieldValueForTitle(val, f.type, titleCtx!, f.options);
|
||
});
|
||
await storage.updateTask(taskId, organizationId, { title: computedTitle });
|
||
}
|
||
|
||
// Side-эффекты (как upload_task_file / REST): кэш, auto-transitions, индексация, SSE
|
||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||
indexTaskAsync(taskId, organizationId).catch(() => {});
|
||
const freshTask = await storage.getTask(taskId, organizationId);
|
||
eventBus.publishEvent({
|
||
type: 'task_updated',
|
||
organizationId,
|
||
data: { taskId, formId: task.formId, task: freshTask, autoTransition: autoResult.changed },
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, updated }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка обновления полей задачи: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// list_users
|
||
register(
|
||
"list_users",
|
||
{
|
||
title: "List Users",
|
||
description: "List all users in the organization",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const all = await storage.getUsersByOrganization(organizationId);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
all.map((u) => ({
|
||
id: u.id,
|
||
email: u.email,
|
||
fullName: formatUserName(u),
|
||
appRole: u.appRole,
|
||
})),
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_user
|
||
register(
|
||
"create_user",
|
||
{
|
||
title: "Create User",
|
||
description: "Create a new user in the organization. If password is not provided, a temporary password will be generated. Custom fields can be set using field codes (will be resolved to field IDs automatically).",
|
||
inputSchema: {
|
||
email: z.string().email().describe("User email address"),
|
||
firstName: z.string().min(1).describe("First name"),
|
||
lastName: z.string().min(1).describe("Last name"),
|
||
password: z.string().optional().describe("Password (optional; if omitted, a temporary password will be generated)"),
|
||
position: z.string().optional().describe("Job position"),
|
||
appRole: z.string().optional().describe("Application role: admin, user, or accountant. Defaults to user."),
|
||
organizationalRoleIds: z.array(z.number()).optional().describe("Array of organizational role IDs to assign"),
|
||
customFields: z.record(z.string()).optional().describe("Custom field values keyed by field code (e.g. {phone: '+7999...'}). Use list_users or the iistwin UI to discover available field codes."),
|
||
},
|
||
},
|
||
async ({ email, firstName, lastName, password, position, appRole, organizationalRoleIds, customFields }) => {
|
||
// Resolve custom field codes to field IDs if provided
|
||
let resolvedCustomFields: Array<{ fieldId: number; value: string | null }> | undefined;
|
||
if (customFields && Object.keys(customFields).length > 0) {
|
||
const allFields = await storage.getUserCustomFields(organizationId);
|
||
resolvedCustomFields = [];
|
||
for (const [code, value] of Object.entries(customFields)) {
|
||
const field = allFields.find((f) => f.code === code);
|
||
if (!field) {
|
||
throw new Error(`Custom field with code "${code}" not found in this organization. Available codes: ${allFields.map((f) => f.code).join(", ")}`);
|
||
}
|
||
resolvedCustomFields.push({ fieldId: field.id, value: value ?? null });
|
||
}
|
||
}
|
||
|
||
const result = await authService.createUser(organizationId, {
|
||
email,
|
||
firstName,
|
||
lastName,
|
||
password,
|
||
position: position || undefined,
|
||
appRole: appRole || "user",
|
||
organizationalRoleIds: organizationalRoleIds || [],
|
||
sendInvite: false,
|
||
customFields: resolvedCustomFields,
|
||
});
|
||
|
||
if (!result.success) {
|
||
throw new Error(result.error || "Failed to create user");
|
||
}
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{
|
||
id: result.user!.id,
|
||
email: result.user!.email,
|
||
firstName: result.user!.firstName,
|
||
lastName: result.user!.lastName,
|
||
position: result.user!.position,
|
||
appRole: result.user!.appRole,
|
||
message: result.message,
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// search_tasks
|
||
register(
|
||
"search_tasks",
|
||
{
|
||
title: "Search Tasks",
|
||
description: "Search tasks by title keyword",
|
||
inputSchema: {
|
||
query: z.string().min(1).describe("Search keyword"),
|
||
form_id: z.number().int().optional().describe("Limit search to a specific form (optional)"),
|
||
limit: z.number().int().min(1).max(100).optional().describe("Max results (default 20)"),
|
||
},
|
||
},
|
||
async ({ query, form_id, limit }) => {
|
||
// form_id передан явно — проверяем доступ к форме
|
||
if (form_id && !isFormAllowed(form_id)) return formDenied(form_id);
|
||
let allTasks: Task[];
|
||
if (form_id) {
|
||
allTasks = await storage.getTasksByForm(form_id, organizationId);
|
||
} else {
|
||
allTasks = (await storage.getTasksByOrganization(organizationId, { limit: 200, minimal: false })) as Task[];
|
||
// Фильтруем выдачу по разрешённым формам
|
||
allTasks = allTasks.filter((t) => isFormAllowed(t.formId));
|
||
}
|
||
|
||
const lq = query.toLowerCase();
|
||
const matched = allTasks
|
||
.filter((t) => t.title?.toLowerCase().includes(lq))
|
||
.slice(0, limit ?? 20);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(matched.map(taskToJson), null, 2),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_form
|
||
register(
|
||
"create_form",
|
||
{
|
||
title: "Create Form",
|
||
description: "Create a new workflow form. After creation use create_form_status to add statuses and add_form_field to add fields.",
|
||
inputSchema: {
|
||
name: z.string().min(1).describe("Form name (e.g. 'Мониторинг сроков')"),
|
||
description: z.string().optional().describe("Optional description of the form"),
|
||
},
|
||
},
|
||
async ({ name, description }) => {
|
||
const actor = await getActor();
|
||
|
||
const form = await storage.createForm({
|
||
organizationId,
|
||
name,
|
||
description: description ?? null,
|
||
createdBy: actor.user.id,
|
||
isActive: true,
|
||
chatEnabled: true,
|
||
chatLayout: "default",
|
||
systemFieldsTabId: null,
|
||
defaultTabId: null,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, form: { id: form.id, name: form.name, description: form.description } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_form_status
|
||
register(
|
||
"create_form_status",
|
||
{
|
||
title: "Create Form Status",
|
||
description: "Add a workflow status to a form. Each form needs at least one initial status before tasks can be created.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The ID of the form to add the status to"),
|
||
name: z.string().min(1).describe("Status name (e.g. 'Новая', 'В работе', 'Завершено')"),
|
||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Hex color code (e.g. '#3b82f6'). Defaults to '#e5e7eb'"),
|
||
is_initial: z.boolean().optional().describe("Mark as the initial/starting status (only one per form). Default false."),
|
||
is_final: z.boolean().optional().describe("Mark as a final/terminal status. Default false."),
|
||
},
|
||
},
|
||
async ({ form_id, name, color, is_initial, is_final }) => {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
|
||
const existingStatuses = await storage.getFormStatuses(form_id, organizationId);
|
||
const position = existingStatuses.length > 0
|
||
? Math.max(...existingStatuses.map((s) => s.position)) + 1
|
||
: 0;
|
||
|
||
const status = await storage.createFormStatus({
|
||
formId: form_id,
|
||
name,
|
||
color: color ?? "#e5e7eb",
|
||
position,
|
||
isInitial: is_initial ?? false,
|
||
isFinal: is_final ?? false,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, status: { id: status.id, name: status.name, color: status.color, isInitial: status.isInitial, isFinal: status.isFinal } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// add_form_field
|
||
register(
|
||
"add_form_field",
|
||
{
|
||
title: "Add Form Field",
|
||
description: "Add a field to a form. Supported types: text, number, date, datetime, select, checkbox, toggle, user, file, textarea, rich-text. For 'select' type, provide options array.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The ID of the form"),
|
||
name: z.string().min(1).describe("Field label/name visible to users (e.g. 'Откуда объект')"),
|
||
type: z.enum(FORM_FIELD_TYPES as [string, ...string[]]).describe("Field type"),
|
||
is_required: z.boolean().optional().describe("Whether the field is required. Default false."),
|
||
options: z.array(z.string()).optional().describe("For 'select' type: list of option labels (e.g. ['Option A', 'Option B']). Required when type is 'select'."),
|
||
placeholder: z.string().optional().describe("Placeholder hint shown in empty inputs"),
|
||
default_value: z.string().optional().describe("Default value pre-filled when creating a task"),
|
||
},
|
||
},
|
||
async ({ form_id, name, type, is_required, options, placeholder, default_value }) => {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
|
||
if (type === "select" && (!options || options.length === 0)) {
|
||
return { content: [{ type: "text" as const, text: "For 'select' type, the 'options' array is required and must not be empty." }], isError: true };
|
||
}
|
||
|
||
const existingFields = await storage.getFormFields(form_id, organizationId);
|
||
const position = existingFields.length > 0
|
||
? Math.max(...existingFields.map((f) => f.position)) + 1
|
||
: 0;
|
||
|
||
// Generate a unique code from the name (lowercase, replace spaces/special chars with _)
|
||
const baseCode = name
|
||
.toLowerCase()
|
||
.replace(/[^a-zа-яё0-9]/gi, "_")
|
||
.replace(/_+/g, "_")
|
||
.replace(/^_|_$/g, "")
|
||
.slice(0, 50) || "field";
|
||
|
||
// Ensure uniqueness within the form
|
||
const existingCodes = new Set(existingFields.map((f) => f.code));
|
||
let code = baseCode;
|
||
let counter = 1;
|
||
while (existingCodes.has(code)) {
|
||
code = `${baseCode}_${counter++}`;
|
||
}
|
||
|
||
// Build options payload for select fields
|
||
const optionsPayload = type === "select" && options && options.length > 0
|
||
? options.map((label, i) => ({ id: String(i + 1), label, color: null }))
|
||
: null;
|
||
|
||
const field = await storage.createFormField({
|
||
formId: form_id,
|
||
tabId: null,
|
||
name,
|
||
code,
|
||
type,
|
||
isRequired: is_required ?? false,
|
||
placeholder: placeholder ?? null,
|
||
defaultValue: default_value ?? null,
|
||
validationRules: null,
|
||
options: optionsPayload,
|
||
position,
|
||
linkedFormId: null,
|
||
buttonActionType: null,
|
||
buttonUrl: null,
|
||
buttonFormId: null,
|
||
companyAutofill: null,
|
||
});
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, field: { id: field.id, name: field.name, code: field.code, type: field.type, isRequired: field.isRequired } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_form
|
||
register(
|
||
"delete_form",
|
||
{
|
||
title: "Delete Form",
|
||
description: "Delete a form and all its tasks, fields, and statuses. This action is irreversible.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The ID of the form to delete"),
|
||
},
|
||
},
|
||
async ({ form_id }) => {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
|
||
await storage.deleteForm(form_id, organizationId);
|
||
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, deleted: { id: form_id, name: form.name } }, null, 2),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Tab Modules ─────────────────────────────────────────────────────
|
||
|
||
// list_tab_modules
|
||
register(
|
||
"list_tab_modules",
|
||
{
|
||
title: "List Tab Modules",
|
||
description: "List all custom tab modules for the organization. Each module shows which forms it is assigned to (form_ids). Use this to see existing modules before creating new ones.",
|
||
inputSchema: {
|
||
include_inactive: z.boolean().optional().describe("Include disabled modules (default: false — only active)"),
|
||
},
|
||
},
|
||
async ({ include_inactive }) => {
|
||
const allModules = await storage.getCustomTabModules(organizationId);
|
||
const filtered = include_inactive ? allModules : allModules.filter((m) => m.isActive !== false);
|
||
const result = await Promise.all(
|
||
filtered.map(async (m) => {
|
||
const formIds = await storage.getFormIdsByTabModuleType(m.type, organizationId);
|
||
return {
|
||
id: m.id,
|
||
type: m.type,
|
||
label: m.label,
|
||
icon: m.icon,
|
||
description: m.description,
|
||
isActive: m.isActive,
|
||
isJsComponent: (m.config as Record<string, unknown>)?.type === "js_component" ||
|
||
Object.keys(m.config as Record<string, unknown>).includes("code"),
|
||
form_ids: formIds,
|
||
createdAt: m.createdAt,
|
||
};
|
||
})
|
||
);
|
||
console.log(`[MCP] list_tab_modules: orgId=${organizationId} total=${allModules.length} returned=${result.length} includeInactive=${include_inactive}`);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: `Found ${result.length} module(s) for organizationId=${organizationId}:\n` +
|
||
JSON.stringify(result, null, 2),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_tab_module
|
||
register(
|
||
"get_tab_module",
|
||
{
|
||
title: "Get Tab Module",
|
||
description: "Get full details of a custom tab module including its config, code (for js_component), and which forms it is assigned to.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module"),
|
||
},
|
||
},
|
||
async ({ module_id }) => {
|
||
const mod = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!mod) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
const formIds = await storage.getFormIdsByTabModuleType(mod.type, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ ...mod, form_ids: formIds }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_tab_module
|
||
register(
|
||
"create_tab_module",
|
||
{
|
||
title: "Create Tab Module (Declarative)",
|
||
description: "Create a new custom declarative tab module with a JSON config. For JS-code tabs, use create_js_tab_module instead.",
|
||
inputSchema: {
|
||
type: z.string().regex(/^[a-z0-9_]+$/).describe("Unique identifier (lowercase letters, digits, underscores only)"),
|
||
label: z.string().describe("Display name shown in the UI"),
|
||
config: z.record(z.any()).describe("Module configuration object (declarative UI config)"),
|
||
icon: z.string().optional().describe("Lucide icon name, default: Puzzle"),
|
||
description: z.string().optional().describe("Short description of the module"),
|
||
inputSchema: z.record(z.any()).optional().describe("JSON schema for per-task editable input fields"),
|
||
form_ids: z.array(z.number().int()).optional().describe("List of form IDs to assign this module to immediately"),
|
||
},
|
||
},
|
||
async ({ type, label, config, icon, description, inputSchema, form_ids }) => {
|
||
const existing = await storage.getCustomTabModuleByType(type, organizationId);
|
||
if (existing) {
|
||
return { content: [{ type: "text" as const, text: `A module with type "${type}" already exists (id=${existing.id})` }], isError: true };
|
||
}
|
||
const createdBy = (await getActor()).user.id;
|
||
const mod = await storage.createCustomTabModule({
|
||
type,
|
||
label,
|
||
config,
|
||
icon: icon ?? "Puzzle",
|
||
description: description ?? null,
|
||
inputSchema: inputSchema ?? null,
|
||
organizationId,
|
||
createdBy,
|
||
});
|
||
const assignedForms: number[] = [];
|
||
if (form_ids && form_ids.length > 0) {
|
||
for (const formId of form_ids) {
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) continue;
|
||
const existingTabs = await storage.getFormTabs(formId, organizationId);
|
||
const maxPos = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position ?? 0)) : -1;
|
||
await storage.createFormTab({
|
||
formId,
|
||
name: label,
|
||
code: `mod_${type}`,
|
||
type,
|
||
position: maxPos + 1,
|
||
});
|
||
assignedForms.push(formId);
|
||
}
|
||
}
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, module: mod, assignedToForms: assignedForms }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_js_tab_module — specialized tool for JS-code tabs
|
||
register(
|
||
"create_js_tab_module",
|
||
{
|
||
title: "Create JS Tab Module",
|
||
description: `Create a new custom tab module with React/JS code. ALWAYS call get_js_coding_reference FIRST.
|
||
|
||
RULES for tab component code:
|
||
- ctx.task — current task object (id, title, formId, currentStatusId, assignedTo, isCompleted, dueDate)
|
||
- ctx.fieldValues — Record<fieldCode, value> for the current task
|
||
- ctx.auth.user — current user
|
||
- ctx.request({ url, method?, data? }) — API call; returns { success, tasks/users/... } (NO .data)
|
||
- ctx.navigate('/forms/<formId>/tasks/' + taskId) — navigate to a task (NEVER /tasks/:id)
|
||
- ctx.libs.React — full React (useState, useEffect, useMemo, etc.)
|
||
- ctx.libs.ui — shadcn components only (Card, Badge, Button, Input, Table*, Select*, etc.)
|
||
- NO imports, NO Ant Design, code MUST end with: return function Widget({ ctx }) { ... }`,
|
||
inputSchema: {
|
||
type: z.string().regex(/^[a-z0-9_]+$/).describe("Unique identifier (lowercase letters, digits, underscores only)"),
|
||
label: z.string().describe("Display name shown as the tab title"),
|
||
code: z.string().min(50).describe("Complete JS component code. Must end with: return function Widget({ ctx }) { ... }"),
|
||
icon: z.string().optional().describe("Lucide icon name (e.g. Link, Puzzle, BarChart). Default: Puzzle"),
|
||
description: z.string().optional().describe("Short description of what this tab does"),
|
||
form_ids: z.array(z.number().int()).optional().describe("List of form IDs to assign this tab to immediately. Use list_forms to find form IDs."),
|
||
},
|
||
},
|
||
async ({ type, label, code, icon, description, form_ids }) => {
|
||
const existing = await storage.getCustomTabModuleByType(type, organizationId);
|
||
if (existing) {
|
||
return { content: [{ type: "text" as const, text: `A module with type "${type}" already exists (id=${existing.id}). Use update_js_tab_module to update it.` }], isError: true };
|
||
}
|
||
const formatted = formatPageCode(code);
|
||
const validationWarnings = validatePageCode(formatted);
|
||
if (validationWarnings.length > 0) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Code validation failed:\n${validationWarnings.join("\n")}\n\nCall get_js_coding_reference to fix these issues.` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
const createdBy = (await getActor()).user.id;
|
||
const mod = await storage.createCustomTabModule({
|
||
type,
|
||
label,
|
||
config: { type: "js_component", code: formatted },
|
||
icon: icon ?? "Puzzle",
|
||
description: description ?? null,
|
||
inputSchema: null,
|
||
organizationId,
|
||
createdBy,
|
||
});
|
||
const assignedForms: number[] = [];
|
||
if (form_ids && form_ids.length > 0) {
|
||
for (const formId of form_ids) {
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) continue;
|
||
const existingTabs = await storage.getFormTabs(formId, organizationId);
|
||
const maxPos = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position ?? 0)) : -1;
|
||
await storage.createFormTab({
|
||
formId,
|
||
name: label,
|
||
code: `mod_${type}`,
|
||
type,
|
||
position: maxPos + 1,
|
||
});
|
||
assignedForms.push(formId);
|
||
}
|
||
}
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
module: { id: mod.id, type: mod.type, label: mod.label, icon: mod.icon },
|
||
assignedToForms: assignedForms,
|
||
hint: assignedForms.length === 0
|
||
? "Module created but not assigned to any form. Use assign_tab_module_to_form to add it to a form."
|
||
: `Module created and assigned to ${assignedForms.length} form(s). Open any task to see the new tab.`,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_js_tab_module — update JS code of an existing js_component tab
|
||
register(
|
||
"update_js_tab_module",
|
||
{
|
||
title: "Update JS Tab Module",
|
||
description: "Update the JS code or metadata of an existing js_component tab module. ALWAYS call get_js_coding_reference FIRST.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module to update"),
|
||
code: z.string().min(50).optional().describe("New JS component code. Must end with: return function Widget({ ctx }) { ... }"),
|
||
label: z.string().optional().describe("New display name"),
|
||
icon: z.string().optional().describe("New Lucide icon name"),
|
||
description: z.string().optional().describe("New description"),
|
||
is_active: z.boolean().optional().describe("Enable or disable the module"),
|
||
form_ids: z.array(z.number().int()).optional().describe("Full list of form IDs this module should be assigned to. Reconciles: adds missing, removes stale. Omit to leave unchanged."),
|
||
},
|
||
},
|
||
async ({ module_id, code, label, icon, description, is_active, form_ids }) => {
|
||
const existing = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (label !== undefined) updates.label = label;
|
||
if (icon !== undefined) updates.icon = icon;
|
||
if (description !== undefined) updates.description = description;
|
||
if (is_active !== undefined) updates.isActive = is_active;
|
||
|
||
if (code !== undefined) {
|
||
const formatted = formatPageCode(code);
|
||
const validationWarnings = validatePageCode(formatted);
|
||
if (validationWarnings.length > 0) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Code validation failed:\n${validationWarnings.join("\n")}\n\nCall get_js_coding_reference to fix these issues.` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
const existingConfig = (existing.config as Record<string, unknown>) ?? {};
|
||
updates.config = { ...existingConfig, type: "js_component", code: formatted };
|
||
}
|
||
|
||
const mod = await storage.updateCustomTabModule(module_id, organizationId, updates);
|
||
|
||
// Reconcile form assignments
|
||
const reconcileResult: { added: number[]; removed: number[] } = { added: [], removed: [] };
|
||
if (form_ids !== undefined) {
|
||
const currentFormIds = await storage.getFormIdsByTabModuleType(existing.type, organizationId);
|
||
const toAdd = form_ids.filter((id) => !currentFormIds.includes(id));
|
||
const toRemove = currentFormIds.filter((id) => !form_ids.includes(id));
|
||
for (const formId of toAdd) {
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) continue;
|
||
const alreadyAssigned = await storage.getFormTabByModuleType(formId, existing.type, organizationId);
|
||
if (alreadyAssigned) continue;
|
||
const existingTabs = await storage.getFormTabs(formId, organizationId);
|
||
const maxPos = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position ?? 0)) : -1;
|
||
await storage.createFormTab({
|
||
formId,
|
||
name: (label ?? existing.label),
|
||
code: `mod_${existing.type}`,
|
||
type: existing.type,
|
||
position: maxPos + 1,
|
||
});
|
||
reconcileResult.added.push(formId);
|
||
}
|
||
for (const formId of toRemove) {
|
||
const tab = await storage.getFormTabByModuleType(formId, existing.type, organizationId);
|
||
if (!tab) continue;
|
||
await storage.deleteFormTab(tab.id, formId, organizationId);
|
||
reconcileResult.removed.push(formId);
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, module: mod, formReconciliation: reconcileResult }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_tab_module
|
||
register(
|
||
"update_tab_module",
|
||
{
|
||
title: "Update Tab Module (Declarative)",
|
||
description: "Update properties of an existing declarative tab module, including form assignments. For JS-code tabs, use update_js_tab_module instead.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module to update"),
|
||
label: z.string().optional().describe("New display name"),
|
||
icon: z.string().optional().describe("New Lucide icon name"),
|
||
description: z.string().optional().describe("New description"),
|
||
config: z.record(z.any()).optional().describe("New configuration object"),
|
||
inputSchema: z.record(z.any()).optional().describe("New input schema"),
|
||
is_active: z.boolean().optional().describe("Enable or disable the module"),
|
||
form_ids: z.array(z.number().int()).optional().describe("Full list of form IDs this module should be assigned to. Existing assignments NOT in this list will be removed (reconciliation). Omit to leave assignments unchanged."),
|
||
},
|
||
},
|
||
async ({ module_id, label, icon, description, config, inputSchema, is_active, form_ids }) => {
|
||
const existing = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (label !== undefined) updates.label = label;
|
||
if (icon !== undefined) updates.icon = icon;
|
||
if (description !== undefined) updates.description = description;
|
||
if (config !== undefined) updates.config = config;
|
||
if (inputSchema !== undefined) updates.inputSchema = inputSchema;
|
||
if (is_active !== undefined) updates.isActive = is_active;
|
||
|
||
const mod = await storage.updateCustomTabModule(module_id, organizationId, updates);
|
||
|
||
// Reconcile form assignments if form_ids was provided
|
||
const reconcileResult: { added: number[]; removed: number[] } = { added: [], removed: [] };
|
||
if (form_ids !== undefined) {
|
||
const currentFormIds = await storage.getFormIdsByTabModuleType(existing.type, organizationId);
|
||
const toAdd = form_ids.filter((id) => !currentFormIds.includes(id));
|
||
const toRemove = currentFormIds.filter((id) => !form_ids.includes(id));
|
||
|
||
for (const formId of toAdd) {
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) continue;
|
||
const alreadyAssigned = await storage.getFormTabByModuleType(formId, existing.type, organizationId);
|
||
if (alreadyAssigned) continue;
|
||
const existingTabs = await storage.getFormTabs(formId, organizationId);
|
||
const maxPos = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position ?? 0)) : -1;
|
||
await storage.createFormTab({
|
||
formId,
|
||
name: (label ?? existing.label),
|
||
code: `mod_${existing.type}`,
|
||
type: existing.type,
|
||
position: maxPos + 1,
|
||
});
|
||
reconcileResult.added.push(formId);
|
||
}
|
||
|
||
for (const formId of toRemove) {
|
||
const tab = await storage.getFormTabByModuleType(formId, existing.type, organizationId);
|
||
if (!tab) continue;
|
||
await storage.deleteFormTab(tab.id, formId, organizationId);
|
||
reconcileResult.removed.push(formId);
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, module: mod, formReconciliation: reconcileResult }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// assign_tab_module_to_form
|
||
register(
|
||
"assign_tab_module_to_form",
|
||
{
|
||
title: "Assign Tab Module to Form",
|
||
description: "Add a custom tab module to a form so it appears as a tab in task detail view. Use list_forms to find form IDs.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module"),
|
||
form_id: z.number().int().describe("The ID of the form to assign the module to"),
|
||
tab_name: z.string().optional().describe("Custom tab name override (default: module label)"),
|
||
},
|
||
},
|
||
async ({ module_id, form_id, tab_name }) => {
|
||
const mod = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!mod) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) return { content: [{ type: "text" as const, text: `Form id=${form_id} not found` }], isError: true };
|
||
|
||
const alreadyAssigned = await storage.getFormTabByModuleType(form_id, mod.type, organizationId);
|
||
if (alreadyAssigned) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Module "${mod.label}" is already assigned to form "${form.name}" (form_tab id=${alreadyAssigned.id})` }],
|
||
};
|
||
}
|
||
|
||
const existingTabs = await storage.getFormTabs(form_id, organizationId);
|
||
const maxPos = existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position ?? 0)) : -1;
|
||
const tab = await storage.createFormTab({
|
||
formId: form_id,
|
||
name: tab_name ?? mod.label,
|
||
code: `mod_${mod.type}`,
|
||
type: mod.type,
|
||
position: maxPos + 1,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
message: `Module "${mod.label}" assigned to form "${form.name}" as tab "${tab.name}"`,
|
||
form_tab: tab,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// remove_tab_module_from_form
|
||
register(
|
||
"remove_tab_module_from_form",
|
||
{
|
||
title: "Remove Tab Module from Form",
|
||
description: "Remove a custom tab module from a form (removes the tab from task view). The module itself is not deleted.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module"),
|
||
form_id: z.number().int().describe("The ID of the form to remove the module from"),
|
||
},
|
||
},
|
||
async ({ module_id, form_id }) => {
|
||
const mod = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!mod) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
|
||
const tab = await storage.getFormTabByModuleType(form_id, mod.type, organizationId);
|
||
if (!tab) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Module "${mod.label}" is not assigned to form id=${form_id}` }],
|
||
};
|
||
}
|
||
await storage.deleteFormTab(tab.id, form_id, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, message: `Module "${mod.label}" removed from form id=${form_id}` }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_tab_module
|
||
register(
|
||
"delete_tab_module",
|
||
{
|
||
title: "Delete Tab Module",
|
||
description: "Permanently delete a custom tab module. This is irreversible. Existing form_tabs using this module type will also stop working.",
|
||
inputSchema: {
|
||
module_id: z.number().int().describe("The ID of the tab module to delete"),
|
||
},
|
||
},
|
||
async ({ module_id }) => {
|
||
const existing = await storage.getCustomTabModule(module_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Tab module not found" }], isError: true };
|
||
await storage.deleteCustomTabModule(module_id, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: module_id, type: existing.type, label: existing.label } }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Custom JS Pages ─────────────────────────────────────────────────
|
||
|
||
// list_custom_pages
|
||
register(
|
||
"list_custom_pages",
|
||
{
|
||
title: "List Custom Pages",
|
||
description: "List all custom JS pages accessible from the sidebar",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const pages = await storage.getCustomPages(organizationId);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
pages.map((p) => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
slug: p.slug,
|
||
description: p.description,
|
||
icon: p.icon,
|
||
isActive: p.isActive,
|
||
formIds: p.formIds,
|
||
createdAt: p.createdAt,
|
||
})),
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_custom_page
|
||
register(
|
||
"get_custom_page",
|
||
{
|
||
title: "Get Custom Page",
|
||
description: "Get full details of a custom JS page including its source code. IMPORTANT: Before editing or rewriting the code, you MUST call get_js_coding_reference to learn the correct APIs, available UI components, and navigation paths.",
|
||
inputSchema: {
|
||
page_id: z.number().int().describe("The ID of the custom page"),
|
||
},
|
||
},
|
||
async ({ page_id }) => {
|
||
const page = await storage.getCustomPage(page_id, organizationId);
|
||
if (!page) return { content: [{ type: "text" as const, text: "Custom page not found" }], isError: true };
|
||
const reminder = `
|
||
\n\n---
|
||
⚠️ BEFORE EDITING THIS CODE — MANDATORY RULES:
|
||
1. Call get_js_coding_reference tool to see ALL available components and APIs.
|
||
2. Use ONLY ctx.libs.ui components (shadcn). Never use Ant Design (ui.Tag, ui.Row, ui.Col, ui.Statistic, ui.Spin are NOT available).
|
||
3. Navigate to tasks with ctx.navigate('/forms/<formId>/tasks/' + taskId). NEVER use ctx.navigate('/tasks/' + id) — that route does not exist and causes 404.
|
||
4. ctx.request() returns { success, tasks, users, forms, ... } — access .tasks not .data.
|
||
5. Code MUST end with: return function Widget({ ctx }) { ... }
|
||
6. ctx.forms[formId] already contains pre-loaded tasks — do NOT fetch them again with ctx.request.
|
||
`.trim();
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(page, null, 2) + "\n\n" + reminder }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// api_get — read-only GET к API CRM: проверка существования эндпоинта и точной структуры
|
||
// ответа ДО написания виджета (предотвращает выдуманные URL и поля). Доступен в любом режиме ключа.
|
||
register(
|
||
"api_get",
|
||
{
|
||
title: "API GET (read-only)",
|
||
description: `Perform an authenticated read-only GET against the CRM API and return { status, json }.
|
||
USE BEFORE WRITING WIDGET CODE: verify the endpoint EXISTS and inspect the EXACT JSON response shape —
|
||
do not invent URLs or field names. status 404 means the URL is wrong (check the spec);
|
||
a JSON shape different from your assumption means your parsing code is wrong.
|
||
url must start with /api/ (same-origin only, no external hosts). GET only.`,
|
||
inputSchema: {
|
||
url: z.string().regex(/^\/api\/[A-Za-z0-9\-/_]+(\?[^\s]*)?$/).describe("Path starting with /api/, e.g. /api/di2/nds/summary or /api/debt/analytics?page=1"),
|
||
},
|
||
},
|
||
async ({ url }) => {
|
||
const actor = await getActor();
|
||
const token = signAccessToken({
|
||
userId: actor.user.id,
|
||
organizationId,
|
||
appRole: actor.user.appRole ?? "user",
|
||
});
|
||
const port = process.env.PORT || 5000;
|
||
const r = await fetch(`http://127.0.0.1:${port}${url}`, {
|
||
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
||
});
|
||
const text = await r.text();
|
||
let json: unknown;
|
||
try {
|
||
json = JSON.parse(text);
|
||
} catch {
|
||
json = text.slice(0, 500);
|
||
}
|
||
const body = JSON.stringify({ status: r.status, json }, null, 2);
|
||
return {
|
||
content: [{ type: "text" as const, text: body.length > 30000 ? body.slice(0, 30000) + "\n…(truncated)" : body }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// validate_custom_page_code — статическая проверка кода ДО сохранения (read-скоуп, без мутаций)
|
||
register(
|
||
"validate_custom_page_code",
|
||
{
|
||
title: "Validate Custom Page Code",
|
||
description: `Statically validate custom page/tab JSX code BEFORE creating/updating a page.
|
||
Runs the same checks as create_custom_page/update_custom_page (404-routes, Ant Design components,
|
||
missing 'return function Widget', .data access bugs, Select composition) and returns warnings.
|
||
Also returns the canonically formatted code — save THIS version via create/update_custom_page.
|
||
Use this to iterate on code without saving broken pages.`,
|
||
inputSchema: {
|
||
code: z.string().describe("React+JSX component code to validate"),
|
||
},
|
||
},
|
||
async ({ code }) => {
|
||
const warnings = validatePageCode(code);
|
||
const formattedCode = formatPageCode(code);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
ok: warnings.length === 0,
|
||
warnings,
|
||
formatted_code: formattedCode,
|
||
note: "Save formatted_code via create_custom_page/update_custom_page. Warnings mean the page likely will NOT render or will misbehave — fix them first.",
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_custom_page
|
||
register(
|
||
"create_custom_page",
|
||
{
|
||
title: "Create Custom Page",
|
||
description: `Create a new custom JS page (accessible by a permanent link, optionally in the sidebar).
|
||
MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules:
|
||
- Only use ctx.libs.ui shadcn components. Ant Design (ui.Tag/Row/Col/Statistic/Spin) does NOT exist.
|
||
- ctx.libs.charts — recharts для графиков (BarChart, Line, ComposedChart, ReferenceArea, ResponsiveContainer и т.д.).
|
||
- Navigate to tasks: ctx.navigate('/forms/<formId>/tasks/' + taskId). Route /tasks/:id does NOT exist.
|
||
- ctx.request() returns { success, tasks/users/forms/... } — use .tasks not .data.
|
||
- ctx.forms[formId] has pre-loaded tasks — never re-fetch them.
|
||
- Code MUST end with: return function Widget({ ctx }) { return React.createElement(...); };`,
|
||
inputSchema: {
|
||
name: z.string().describe("Display name of the page"),
|
||
slug: z.string().regex(/^[a-z0-9-]+$/).describe("URL-friendly identifier (lowercase, digits, hyphens)"),
|
||
code: z.string().describe("React+JSX component code. Must end with: return function Widget({ ctx }) { ... }"),
|
||
description: z.string().optional().describe("Short description"),
|
||
icon: z.string().optional().describe("Lucide icon name, default: FileCode"),
|
||
form_ids: z.array(z.number().int()).optional().describe("IDs of associated forms (used in ctx.forms)"),
|
||
show_in_sidebar: z.boolean().optional().describe("Show in the app sidebar (default true). The page is ALWAYS reachable by its permanent URL /pages/<slug> — bookmarkable regardless of this flag."),
|
||
hide_header: z.boolean().optional().describe("Hide the platform header (page title + description bar rendered above the widget). Use true for fullscreen reports/dashboards. Default false."),
|
||
},
|
||
},
|
||
async ({ name, slug, code, description, icon, form_ids, show_in_sidebar, hide_header }) => {
|
||
const existing = await storage.getCustomPageBySlug(slug, organizationId);
|
||
if (existing) {
|
||
return { content: [{ type: "text" as const, text: `A page with slug "${slug}" already exists` }], isError: true };
|
||
}
|
||
const createdBy = (await getActor()).user.id;
|
||
const createWarnings = validatePageCode(code);
|
||
const formattedCode = formatPageCode(code);
|
||
|
||
const page = await storage.createCustomPage({
|
||
name,
|
||
slug,
|
||
code: formattedCode,
|
||
description: description ?? null,
|
||
icon: icon ?? "FileCode",
|
||
formIds: form_ids ?? [],
|
||
organizationId,
|
||
createdBy,
|
||
showInSidebar: show_in_sidebar ?? true,
|
||
hideHeader: hide_header === true,
|
||
});
|
||
const createResult: Record<string, unknown> = {
|
||
success: true,
|
||
page,
|
||
url: `/pages/${page.slug}`,
|
||
bookmark_url: `${process.env.APP_BASE_URL || "https://iistwin.ru"}/pages/${page.slug}`,
|
||
};
|
||
if (createWarnings.length > 0) {
|
||
createResult.warnings = createWarnings;
|
||
createResult.action_required = "The page was saved but contains potential bugs listed in 'warnings'. Fix them before considering the task complete.";
|
||
}
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(createResult, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_custom_page
|
||
register(
|
||
"update_custom_page",
|
||
{
|
||
title: "Update Custom Page",
|
||
description: `Update properties or code of an existing custom JS page.
|
||
MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules:
|
||
- Only use ctx.libs.ui shadcn components. Ant Design (ui.Tag/Row/Col/Statistic/Spin) does NOT exist.
|
||
- ctx.libs.charts — recharts для графиков (BarChart, Line, ComposedChart, ReferenceArea, ResponsiveContainer и т.д.).
|
||
- Navigate to tasks: ctx.navigate('/forms/<formId>/tasks/' + taskId). Route /tasks/:id does NOT exist — it causes 404.
|
||
- ctx.request() returns { success, tasks/users/forms/... } — use .tasks not .data.
|
||
- ctx.forms[formId] has pre-loaded tasks — never re-fetch them with ctx.request.
|
||
- Code MUST end with: return function Widget({ ctx }) { return React.createElement(...); };`,
|
||
inputSchema: {
|
||
page_id: z.number().int().describe("The ID of the custom page to update"),
|
||
name: z.string().optional().describe("New display name"),
|
||
code: z.string().optional().describe("New React+JSX source code. MUST end with: return function Widget({ ctx }) { ... }"),
|
||
description: z.string().optional().describe("New description"),
|
||
icon: z.string().optional().describe("New Lucide icon name"),
|
||
form_ids: z.array(z.number().int()).optional().describe("New list of associated form IDs"),
|
||
is_active: z.boolean().optional().describe("Enable or disable the page"),
|
||
show_in_sidebar: z.boolean().optional().describe("Show/hide in the app sidebar. The page is always reachable by its permanent URL /pages/<slug>."),
|
||
hide_header: z.boolean().optional().describe("Hide the platform header (title + description bar) above the widget"),
|
||
},
|
||
},
|
||
async ({ page_id, name, code, description, icon, form_ids, is_active, show_in_sidebar, hide_header }) => {
|
||
const existing = await storage.getCustomPage(page_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Custom page not found" }], isError: true };
|
||
|
||
const warnings = code ? validatePageCode(code) : [];
|
||
const formattedCode = code ? formatPageCode(code) : undefined;
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = name;
|
||
if (formattedCode !== undefined) updates.code = formattedCode;
|
||
if (description !== undefined) updates.description = description;
|
||
if (icon !== undefined) updates.icon = icon;
|
||
if (form_ids !== undefined) updates.formIds = form_ids;
|
||
if (is_active !== undefined) updates.isActive = is_active;
|
||
if (show_in_sidebar !== undefined) updates.showInSidebar = show_in_sidebar;
|
||
if (hide_header !== undefined) updates.hideHeader = hide_header;
|
||
|
||
const page = await storage.updateCustomPage(page_id, organizationId, updates as any);
|
||
const result: Record<string, unknown> = {
|
||
success: true,
|
||
page,
|
||
url: `/pages/${page.slug}`,
|
||
bookmark_url: `${process.env.APP_BASE_URL || "https://iistwin.ru"}/pages/${page.slug}`,
|
||
};
|
||
if (warnings.length > 0) {
|
||
result.warnings = warnings;
|
||
result.action_required = "The page was saved but contains potential bugs listed in 'warnings'. Fix them before considering the task complete.";
|
||
}
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_custom_page
|
||
register(
|
||
"delete_custom_page",
|
||
{
|
||
title: "Delete Custom Page",
|
||
description: "Delete a custom JS page. This is irreversible.",
|
||
inputSchema: {
|
||
page_id: z.number().int().describe("The ID of the custom page to delete"),
|
||
},
|
||
},
|
||
async ({ page_id }) => {
|
||
const existing = await storage.getCustomPage(page_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Custom page not found" }], isError: true };
|
||
await storage.deleteCustomPage(page_id, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: page_id, name: existing.name, slug: existing.slug } }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── JS Coding Reference ────────────────────────────────────────────────────
|
||
|
||
// get_js_coding_reference
|
||
register(
|
||
"get_js_coding_reference",
|
||
{
|
||
title: "Get JS Coding Reference",
|
||
description: "Returns the complete reference guide for writing JS custom pages and tab components. ALWAYS call this tool BEFORE writing any JS page or tab component code to ensure you use correct APIs, component names, and patterns.",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const ref = `
|
||
# JS Custom Pages & Tab Components — Developer Reference
|
||
|
||
## ⚠️ CRITICAL RULES
|
||
1. The last expression/statement MUST return a React function component.
|
||
2. Never use Ant Design (antd) — only shadcn/ui components listed below.
|
||
3. Never import anything — all libraries come from \`ctx\`.
|
||
4. Code runs in a sandboxed browser environment (no Node.js, no require).
|
||
|
||
---
|
||
|
||
## ctx Object Structure
|
||
|
||
\`\`\`
|
||
ctx = {
|
||
libs: {
|
||
React, // full React library (useState, useEffect, useRef, useMemo, etc.)
|
||
ui: { ... }, // shadcn/ui components (see full list below)
|
||
charts, // recharts for graphs (BarChart, Bar, Line, ComposedChart, XAxis, YAxis,
|
||
// CartesianGrid, Tooltip, Legend, ResponsiveContainer, ReferenceArea, ...)
|
||
},
|
||
|
||
// For CUSTOM PAGES only:
|
||
forms, // Record<formId, Task[]> — pre-loaded tasks by form ID
|
||
allForms, // Array<{ id, name }> — all forms in org
|
||
page, // { id, name, slug, description } — current page info
|
||
|
||
// For TAB COMPONENTS only:
|
||
task, // current task object
|
||
fieldValues, // Record<fieldCode, value> — current task field values
|
||
|
||
// For ALL:
|
||
auth: { user }, // current user
|
||
request(opts), // async API call, returns parsed JSON
|
||
navigate(path), // navigate to a URL
|
||
message: {
|
||
success(msg),
|
||
error(msg),
|
||
info(msg),
|
||
},
|
||
}
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## ctx.request — API Calls
|
||
|
||
\`\`\`javascript
|
||
// GET request
|
||
const data = await ctx.request({ url: '/api/tasks?formId=5&limit=100' });
|
||
// data = { success: true, tasks: [...] } ← tasks array is at .tasks
|
||
|
||
// GET users
|
||
const data = await ctx.request({ url: '/api/users' });
|
||
// data = { success: true, users: [...] } ← users array is at .users
|
||
|
||
// GET forms
|
||
const data = await ctx.request({ url: '/api/forms' });
|
||
// data = { success: true, forms: [...] } ← forms array is at .forms
|
||
|
||
// GET task field values
|
||
const data = await ctx.request({ url: '/api/tasks/123/field-values' });
|
||
// data = { success: true, fieldValues: [{ fieldId, value, field: { fieldCode, name } }] }
|
||
|
||
// PATCH — update a single field (partial update, preserves other fields)
|
||
await ctx.request({
|
||
method: 'PATCH',
|
||
url: '/api/tasks/123/field-values/456',
|
||
data: { value: 'New value' }
|
||
});
|
||
|
||
// PATCH — bulk update multiple fields (partial update, preserves other fields)
|
||
await ctx.request({
|
||
method: 'PATCH',
|
||
url: '/api/tasks/123/field-values',
|
||
data: { fieldId1: 'value1', fieldId2: 'value2' }
|
||
});
|
||
|
||
// POST — save field values (merge by default, pass ?replace=true for replace-all)
|
||
await ctx.request({
|
||
method: 'POST',
|
||
url: '/api/tasks/123/field-values',
|
||
data: { fieldValues: [{ fieldId: 456, value: 'New value' }] }
|
||
});
|
||
|
||
// POST — create task
|
||
const data = await ctx.request({
|
||
method: 'POST',
|
||
url: '/api/tasks',
|
||
data: { formId: 5, title: 'New task' }
|
||
});
|
||
// data = { success: true, task: { id, title, ... } }
|
||
|
||
// PUT — update task
|
||
await ctx.request({
|
||
method: 'PUT',
|
||
url: '/api/tasks/123',
|
||
data: { title: 'Updated' }
|
||
});
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## Available UI Components (ctx.libs.ui)
|
||
|
||
### Layout / Container
|
||
- \`Card\`, \`CardContent\`, \`CardHeader\`, \`CardTitle\`, \`CardDescription\`, \`CardFooter\`
|
||
- \`Separator\`
|
||
- \`ScrollArea\`
|
||
- \`Tabs\`, \`TabsList\`, \`TabsTrigger\`, \`TabsContent\`
|
||
|
||
### Data Display
|
||
- \`Table\`, \`TableHeader\`, \`TableBody\`, \`TableRow\`, \`TableHead\`, \`TableCell\`
|
||
- \`Badge\` — props: variant="default|secondary|destructive|outline"
|
||
- \`Progress\` — props: value={0-100}
|
||
- \`Alert\`, \`AlertTitle\`, \`AlertDescription\`
|
||
|
||
### Form Controls
|
||
- \`Button\` — props: variant="default|outline|ghost|destructive", size="sm|default|lg"
|
||
- \`Input\` — standard HTML input props
|
||
- \`Textarea\`
|
||
- \`Label\`
|
||
- \`Switch\` — props: checked, onCheckedChange
|
||
- \`Select\`, \`SelectTrigger\`, \`SelectContent\`, \`SelectItem\`, \`SelectValue\`
|
||
⚠️ ALL FIVE must be destructured and used together. SelectItem alone will crash.
|
||
|
||
### ✅ Correct Select usage
|
||
\`\`\`javascript
|
||
const { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } = ui;
|
||
|
||
React.createElement(Select, { value: filter, onValueChange: v => setFilter(v) },
|
||
React.createElement(SelectTrigger, { className: 'h-7 text-xs w-32' },
|
||
React.createElement(SelectValue, { placeholder: 'Выберите...' })
|
||
),
|
||
React.createElement(SelectContent, null,
|
||
React.createElement(SelectItem, { value: 'all' }, 'Все'),
|
||
React.createElement(SelectItem, { value: 'active' }, 'В работе'),
|
||
React.createElement(SelectItem, { value: 'closed' }, 'Закрытые')
|
||
)
|
||
)
|
||
\`\`\`
|
||
|
||
### Icons — use emoji or text labels
|
||
Icons from lucide-react are NOT available in ctx. Use emoji: ✅ ❌ ⚠️ 📋 👤
|
||
|
||
---
|
||
|
||
## Correct Code Patterns
|
||
|
||
### ✅ Minimal custom page
|
||
\`\`\`javascript
|
||
const { React, ui } = ctx.libs;
|
||
const { useState, useEffect } = React;
|
||
const { Card, CardContent, CardHeader, CardTitle, Table, TableBody,
|
||
TableRow, TableCell, TableHead, TableHeader, Badge } = ui;
|
||
|
||
function Page({ ctx }) {
|
||
const tasks = ctx.forms[FORM_ID] || []; // Replace FORM_ID with actual number
|
||
|
||
return React.createElement('div', { className: 'p-4 space-y-4' },
|
||
React.createElement(Card, null,
|
||
React.createElement(CardHeader, { className: 'pb-2' },
|
||
React.createElement(CardTitle, { className: 'text-sm' }, 'Список')
|
||
),
|
||
React.createElement(CardContent, null,
|
||
React.createElement(Table, null,
|
||
React.createElement(TableHeader, null,
|
||
React.createElement(TableRow, null,
|
||
React.createElement(TableHead, null, 'Название'),
|
||
React.createElement(TableHead, null, 'Статус')
|
||
)
|
||
),
|
||
React.createElement(TableBody, null,
|
||
tasks.map(t =>
|
||
React.createElement(TableRow, { key: t.id },
|
||
React.createElement(TableCell, null, t.title),
|
||
React.createElement(TableCell, null,
|
||
React.createElement(Badge, { variant: 'secondary' },
|
||
t.isCompleted ? 'Завершён' : 'В работе'
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// ✅ ALWAYS end with this line — returns the component function
|
||
return function Widget({ ctx }) {
|
||
return React.createElement(Page, { ctx });
|
||
};
|
||
\`\`\`
|
||
|
||
### ✅ Loading data with useEffect
|
||
\`\`\`javascript
|
||
const { React, ui } = ctx.libs;
|
||
const { useState, useEffect } = React;
|
||
const { Card, CardContent } = ui;
|
||
|
||
function Page({ ctx }) {
|
||
const [users, setUsers] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const res = await ctx.request({ url: '/api/users' });
|
||
setUsers(res.users || []); // ← NOTE: .users not .data
|
||
setLoading(false);
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
if (loading) return React.createElement('div', { className: 'p-4 text-sm text-muted-foreground' }, 'Загрузка...');
|
||
|
||
return React.createElement(Card, null,
|
||
React.createElement(CardContent, { className: 'p-4' },
|
||
React.createElement('p', null, 'Пользователей: ' + users.length)
|
||
)
|
||
);
|
||
}
|
||
|
||
return function Widget({ ctx }) {
|
||
return React.createElement(Page, { ctx });
|
||
};
|
||
\`\`\`
|
||
|
||
### ✅ Navigate to a task
|
||
\`\`\`javascript
|
||
// CORRECT — include formId in the URL
|
||
onClick: () => ctx.navigate('/forms/' + task.formId + '/tasks/' + task.id)
|
||
// Or with a known formId:
|
||
onClick: () => ctx.navigate('/forms/20/tasks/' + taskId)
|
||
// ❌ WRONG — /tasks/:id does not exist, causes 404:
|
||
// onClick: () => ctx.navigate('/tasks/' + taskId)
|
||
\`\`\`
|
||
|
||
### ✅ Minimal JS Tab Component (tab context — ctx.task available)
|
||
\`\`\`javascript
|
||
const { React, ui } = ctx.libs;
|
||
const { useState, useEffect } = React;
|
||
const { Card, CardContent, CardHeader, CardTitle, Badge } = ui;
|
||
|
||
function TabWidget({ ctx }) {
|
||
// In tabs: ctx.task = current task, ctx.fieldValues = field values
|
||
const task = ctx.task;
|
||
if (!task) return React.createElement('div', { className: 'p-4 text-sm text-muted-foreground' }, 'Задача не выбрана');
|
||
|
||
return React.createElement(Card, null,
|
||
React.createElement(CardHeader, { className: 'pb-2' },
|
||
React.createElement(CardTitle, { className: 'text-sm' }, 'Задача #' + task.id)
|
||
),
|
||
React.createElement(CardContent, null,
|
||
React.createElement('p', { className: 'text-sm' }, task.title),
|
||
React.createElement(Badge, { variant: task.isCompleted ? 'secondary' : 'default' },
|
||
task.isCompleted ? 'Завершён' : 'В работе'
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
return function Widget({ ctx }) {
|
||
return React.createElement(TabWidget, { ctx });
|
||
};
|
||
\`\`\`
|
||
|
||
### ✅ Show toast notification
|
||
\`\`\`javascript
|
||
ctx.message.success('Сохранено!');
|
||
ctx.message.error('Ошибка!');
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## Page vs Tab — Key Differences
|
||
|
||
| | Custom Page | JS Tab Component |
|
||
|--|--|--|
|
||
| ctx.task | null (no task) | current task object |
|
||
| ctx.fieldValues | {} (empty) | current task field values |
|
||
| ctx.forms[id] | pre-loaded task arrays | NOT available |
|
||
| Use case | Dashboards, reports | Per-task panels |
|
||
|
||
## Task Object Shape
|
||
\`\`\`
|
||
{
|
||
id, title,
|
||
formId, currentStatusId,
|
||
assignedTo, // user ID or null
|
||
isCompleted, // boolean
|
||
dueDate, // ISO string or null, e.g. "2025-12-31T00:00:00.000Z"
|
||
createdAt, updatedAt
|
||
}
|
||
\`\`\`
|
||
|
||
## User Object Shape (from /api/users)
|
||
\`\`\`
|
||
{
|
||
id, email,
|
||
firstName, lastName,
|
||
fullName, // pre-computed "Иван Иванов"
|
||
role, isActive
|
||
}
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## ❌ NEVER DO THIS
|
||
\`\`\`javascript
|
||
// ❌ Wrong: Ant Design components
|
||
ui.Tag, ui.Row, ui.Col, ui.Statistic, ui.Spin, ui.Table (antd)
|
||
|
||
// ❌ Wrong: API response format
|
||
const res = await ctx.request(...);
|
||
res.data // WRONG — there is no .data
|
||
res.items // WRONG
|
||
|
||
// ❌ Wrong: end the file without returning a component
|
||
React.createElement(MyComponent) // This creates an element, not a component
|
||
|
||
// ❌ Wrong: imports
|
||
import React from 'react'; // NO imports allowed
|
||
\`\`\`
|
||
`.trim();
|
||
return { content: [{ type: "text" as const, text: ref }] };
|
||
}
|
||
);
|
||
|
||
// ── Automations ────────────────────────────────────────────────────────────
|
||
|
||
// list_automations
|
||
register(
|
||
"list_automations",
|
||
{
|
||
title: "List Automations",
|
||
description: "List all JS automations in the organization",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const list = await storage.getAutomations(organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(list.map(a => ({
|
||
id: a.id, name: a.name, description: a.description, trigger: a.trigger,
|
||
triggerConfig: a.triggerConfig, isActive: a.isActive, createdAt: a.createdAt,
|
||
})), null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_automation
|
||
register(
|
||
"get_automation",
|
||
{
|
||
title: "Get Automation",
|
||
description: "Get full details of an automation including its JavaScript code",
|
||
inputSchema: {
|
||
automation_id: z.number().int().describe("The ID of the automation"),
|
||
},
|
||
},
|
||
async ({ automation_id }) => {
|
||
const item = await storage.getAutomation(automation_id, organizationId);
|
||
if (!item) return { content: [{ type: "text" as const, text: "Automation not found" }], isError: true };
|
||
return { content: [{ type: "text" as const, text: JSON.stringify(item, null, 2) }] };
|
||
}
|
||
);
|
||
|
||
// create_automation
|
||
register(
|
||
"create_automation",
|
||
{
|
||
title: "Create Automation",
|
||
description: `Create a new JavaScript automation. The code runs server-side in a Node.js VM sandbox.
|
||
|
||
Available context object \`ctx\`:
|
||
|
||
Variables:
|
||
- ctx.organizationId — current organization ID
|
||
- ctx.triggerData — event data for triggered automations
|
||
- ctx.result — result object returned to CRM
|
||
|
||
Trigger data shapes (ctx.triggerData):
|
||
- task.before_create: { formId, task: { formId, title, currentStatusId, dueDate, customFields } }
|
||
- task.created: { formId, taskId, task: { ... } }
|
||
- task.status_changed: { formId, taskId, oldStatusId, newStatusId, task: { ... }, userId }
|
||
- qr.scan: { template, objectId, scannedByUserId }
|
||
- schedule: { trigger: 'schedule', scheduledAt }
|
||
|
||
Logging:
|
||
- ctx.log(msg) — append message to execution log
|
||
|
||
Forms:
|
||
- ctx.forms.list() — list all forms
|
||
- ctx.forms.get(formId) — get one form
|
||
- ctx.forms.getFields(formId) — get form fields
|
||
- ctx.forms.getStatuses(formId) — get form statuses
|
||
|
||
Tasks:
|
||
- ctx.tasks.list(formId) — list tasks of a form
|
||
- ctx.tasks.get(taskId) — get one task
|
||
- ctx.tasks.create({ formId, title, assignedTo?, dueDate? }) — create task
|
||
- ctx.tasks.update(taskId, updates) — update task fields
|
||
- ctx.tasks.delete(taskId) — delete task
|
||
- ctx.tasks.getFieldValues(taskId) — get task field values array
|
||
- ctx.tasks.getAssignees(taskId) — get task assignees
|
||
- ctx.tasks.setFieldValue(taskId, fieldCode, value) — set a field value
|
||
- ctx.tasks.sendMessage(taskId, message) — post a message to the task chat as 'Система' (messageType 'status_change', 'Статус' badge, no push notifications)
|
||
- ctx.tasks.scheduleStatusChange(taskId, targetStatusId, delayMs) — schedule a delayed status change: a worker moves the task to targetStatusId after delayMs, but only if the task is still in its current status (a manual status change cancels the delayed one). Audit, SSE, push and task.status_changed automations fire as usual.
|
||
|
||
Users:
|
||
- ctx.users.list() — list organization users
|
||
- ctx.users.get(userId) — get one user
|
||
- ctx.users.update(userId, updates) — update user fields (firstName, lastName, position, statusId, etc.)
|
||
- ctx.users.updateStatus(userId, statusId) — change user status
|
||
- ctx.users.setFieldValue(userId, fieldCodeOrId, value) — set a user profile custom field value by field id or code (with audit as 'Автоматизация'). For fields of type 'history-number' the numeric value is appended to user_field_history (linked to the trigger task) and becomes the field value itself (last rating); the current-month average is auto-recalculated into the field specified in the history-number field's options.avgFieldCode (if not set — into the same field); a non-numeric value throws an error.
|
||
- ctx.users.recalcFieldAverage(userId, fieldCodeOrId) — recalculate the current-month average of a history-number field from user_field_history (use from schedule automations for monthly reset; clears the target average field if no entries this month).
|
||
|
||
Trigger types: 'manual', 'task.before_create', 'task.created', 'task.status_changed', 'qr.scan', 'schedule'
|
||
TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number } — statusId applies only to task.status_changed and limits firing to transitions INTO that status
|
||
TriggerConfig for qr.scan: { templateKey: string }
|
||
TriggerConfig for schedule: { time: "HH:MM" } — daily run at this Moscow time (MSK, UTC+3); reruns within the same day are prevented via last_scheduled_run_at
|
||
|
||
Result object ctx.result:
|
||
- { allow: false, error: '...' } — block task creation (task.before_create)
|
||
- { action: 'open_task', taskId: number } — open a task (qr.scan)
|
||
- { action: 'show_list', tasks: [{ id, title }] } — show task list (qr.scan)
|
||
- { action: 'create_task', formId: number, prefill: Record<string, unknown> } — open create dialog (qr.scan)
|
||
- { action: 'error', message: '...' } — show error (qr.scan)
|
||
|
||
Offline execution (PWA / qr.scan):
|
||
- run_offline: if true, automation is downloaded to PWA and can run without internet
|
||
- client_compatible: must be true for run_offline; confirms code is safe to run on client device
|
||
- ctx.result.actions.push({ type: 'notify', title?, message, variant? }) — show toast notification
|
||
- ctx.result.actions.push({ type: 'navigate', path }) — navigate in PWA
|
||
- ctx.result.actions.push({ type: 'updateFormCache', taskId, values: Record<fieldCode, value> }) — update task fields offline
|
||
- ctx.result.actions.push({ type: 'createOfflineTask', formId, title?, values: Record<fieldCode, value> }) — create task offline
|
||
|
||
To block task creation from task.before_create, set: ctx.result = { allow: false, error: 'Reason...' }`,
|
||
inputSchema: {
|
||
name: z.string().describe("Automation display name"),
|
||
code: z.string().describe("JavaScript code to execute"),
|
||
trigger: z.enum(["manual", "task.before_create", "task.created", "task.status_changed", "qr.scan", "schedule"]).default("manual").describe("When this automation fires"),
|
||
description: z.string().optional().describe("Optional description"),
|
||
trigger_config: z.record(z.unknown()).optional().describe("Trigger configuration JSON, e.g. { formId: 5 }, { templateKey: 'equipment-scan' } or { time: '08:00' } for schedule (daily, MSK)"),
|
||
is_active: z.boolean().optional().describe("Whether the automation is active"),
|
||
run_offline: z.boolean().optional().describe("Allow execution in PWA without internet"),
|
||
client_compatible: z.boolean().optional().describe("Code is safe to run on client device (required for run_offline)"),
|
||
},
|
||
},
|
||
async ({ name, code, trigger, description, trigger_config, is_active, run_offline, client_compatible }) => {
|
||
const createdBy = (await getActor()).user.id;
|
||
const item = await storage.createAutomation({
|
||
organizationId,
|
||
name,
|
||
code,
|
||
trigger: trigger ?? "manual",
|
||
description: description ?? null,
|
||
triggerConfig: trigger_config ?? null,
|
||
isActive: is_active ?? true,
|
||
runOffline: run_offline ?? false,
|
||
clientCompatible: client_compatible ?? false,
|
||
createdBy,
|
||
});
|
||
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, automation: item }, null, 2) }] };
|
||
}
|
||
);
|
||
|
||
// update_automation
|
||
register(
|
||
"update_automation",
|
||
{
|
||
title: "Update Automation",
|
||
description: "Update the code or configuration of an existing automation",
|
||
inputSchema: {
|
||
automation_id: z.number().int().describe("The ID of the automation to update"),
|
||
name: z.string().optional().describe("New name"),
|
||
code: z.string().optional().describe("New JavaScript code"),
|
||
description: z.string().optional().describe("New description"),
|
||
trigger: z.enum(["manual", "task.before_create", "task.created", "task.status_changed", "qr.scan", "schedule"]).optional().describe("New trigger type ('schedule' = daily at trigger_config.time HH:MM MSK)"),
|
||
trigger_config: z.record(z.unknown()).optional().describe("New trigger config JSON"),
|
||
is_active: z.boolean().optional().describe("Enable or disable"),
|
||
run_offline: z.boolean().optional().describe("Allow execution in PWA without internet"),
|
||
client_compatible: z.boolean().optional().describe("Code is safe to run on client device"),
|
||
},
|
||
},
|
||
async ({ automation_id, name, code, description, trigger, trigger_config, is_active, run_offline, client_compatible }) => {
|
||
const existing = await storage.getAutomation(automation_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Automation not found" }], isError: true };
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = name;
|
||
if (code !== undefined) updates.code = code;
|
||
if (description !== undefined) updates.description = description;
|
||
if (trigger !== undefined) updates.trigger = trigger;
|
||
if (run_offline !== undefined) updates.runOffline = run_offline;
|
||
if (client_compatible !== undefined) updates.clientCompatible = client_compatible;
|
||
if (trigger_config !== undefined) updates.triggerConfig = trigger_config;
|
||
if (is_active !== undefined) updates.isActive = is_active;
|
||
const item = await storage.updateAutomation(automation_id, organizationId, updates as any);
|
||
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, automation: item }, null, 2) }] };
|
||
}
|
||
);
|
||
|
||
// delete_automation
|
||
register(
|
||
"delete_automation",
|
||
{
|
||
title: "Delete Automation",
|
||
description: "Delete an automation permanently",
|
||
inputSchema: {
|
||
automation_id: z.number().int().describe("The ID of the automation to delete"),
|
||
},
|
||
},
|
||
async ({ automation_id }) => {
|
||
const existing = await storage.getAutomation(automation_id, organizationId);
|
||
if (!existing) return { content: [{ type: "text" as const, text: "Automation not found" }], isError: true };
|
||
await storage.deleteAutomation(automation_id, organizationId);
|
||
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: automation_id, name: existing.name } }, null, 2) }] };
|
||
}
|
||
);
|
||
|
||
// ── Task Relations ──────────────────────────────────────────────────────────
|
||
|
||
// link_tasks
|
||
register(
|
||
"link_tasks",
|
||
{
|
||
title: "Link Tasks",
|
||
description:
|
||
"Create a manual relation between two tasks (parent→child). " +
|
||
"Use type='parent' when the related_task_id should become the parent of task_id, " +
|
||
"or type='child' when related_task_id should become a child of task_id. " +
|
||
"Both tasks must belong to the same organization. Duplicate links are silently ignored (upsert). " +
|
||
"After linking, both tasks will show each other in their «Связанные» tab.",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The ID of the current (base) task"),
|
||
related_task_id: z.number().int().describe("The ID of the task to link to"),
|
||
type: z
|
||
.enum(["parent", "child"])
|
||
.describe(
|
||
"'parent' — related_task_id is the parent of task_id; " +
|
||
"'child' — related_task_id is the child of task_id"
|
||
),
|
||
},
|
||
},
|
||
async ({ task_id, related_task_id, type }) => {
|
||
const [currentTask, relatedTask] = await Promise.all([
|
||
storage.getTask(task_id, organizationId),
|
||
storage.getTask(related_task_id, organizationId),
|
||
]);
|
||
if (!currentTask) return { content: [{ type: "text" as const, text: `Task id=${task_id} not found` }], isError: true };
|
||
if (!relatedTask) return { content: [{ type: "text" as const, text: `Task id=${related_task_id} not found` }], isError: true };
|
||
// Проверка доступа к формам обеих задач
|
||
if (!isFormAllowed(currentTask.formId)) return formDenied(currentTask.formId);
|
||
if (!isFormAllowed(relatedTask.formId)) return formDenied(relatedTask.formId);
|
||
|
||
const relation = await storage.upsertTaskRelation(
|
||
type === "parent"
|
||
? { parentTaskId: related_task_id, childTaskId: task_id, fieldId: null, organizationId }
|
||
: { parentTaskId: task_id, childTaskId: related_task_id, fieldId: null, organizationId }
|
||
);
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
message: type === "parent"
|
||
? `Task #${related_task_id} is now a parent of task #${task_id}`
|
||
: `Task #${related_task_id} is now a child of task #${task_id}`,
|
||
relation,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_related_tasks
|
||
register(
|
||
"get_related_tasks",
|
||
{
|
||
title: "Get Related Tasks",
|
||
description:
|
||
"Return the full tree of tasks related to a given task via task_relations. " +
|
||
"Returns two arrays: parents (tasks that are ancestors) and children (tasks that are descendants). " +
|
||
"Each node recursively contains its own parents/children via the 'nodes' field (depth-first, max 10 levels). " +
|
||
"Each node includes: id, title, formId, formName, statusName, statusColor, isFinal, fieldName.",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The ID of the task to get relations for"),
|
||
},
|
||
},
|
||
async ({ task_id }) => {
|
||
const task = await storage.getTask(task_id, organizationId);
|
||
if (!task) return { content: [{ type: "text" as const, text: `Task id=${task_id} not found` }], isError: true };
|
||
// Проверка доступа к форме задачи
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const related = await storage.getRelatedTasks(task_id, organizationId);
|
||
// Отсекаем узлы дерева связей из недоступных форм (рекурсивно по полю nodes)
|
||
const filterNodes = (nodes: any[]): any[] =>
|
||
(Array.isArray(nodes) ? nodes : [])
|
||
.filter((n) => n && typeof n.formId === 'number' && isFormAllowed(n.formId))
|
||
.map((n) => ({ ...n, nodes: filterNodes(n.nodes) }));
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
task_id,
|
||
parents: filterNodes((related as any).parents),
|
||
children: filterNodes((related as any).children),
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// semantic_search
|
||
register(
|
||
"semantic_search",
|
||
{
|
||
title: "Semantic Search",
|
||
description:
|
||
"Search across forms, tasks, and chat messages using natural language (vector similarity). " +
|
||
"Returns the most relevant results ranked by similarity. " +
|
||
"Use this when the user asks to 'find', 'search', 'look for', or 'show me' something without knowing exact names or IDs. " +
|
||
"Parameters: query (required) — the natural language search query; " +
|
||
"entity_types (optional) — filter by type: 'form', 'task', 'task_message' (default: all); " +
|
||
"limit (optional) — max results to return (default: 10, max: 50).",
|
||
inputSchema: {
|
||
query: z.string().describe("Natural language search query"),
|
||
entity_types: z
|
||
.array(z.enum(["form", "task", "task_message"]))
|
||
.optional()
|
||
.describe("Filter by entity types (default: all)"),
|
||
limit: z.number().int().min(1).max(50).optional().describe("Max results (default: 10)"),
|
||
},
|
||
},
|
||
async ({ query, entity_types, limit }) => {
|
||
try {
|
||
const results = await semanticSearch(
|
||
organizationId,
|
||
query,
|
||
entity_types as EntityType[] | undefined,
|
||
limit ?? 10
|
||
);
|
||
|
||
if (results.length === 0) {
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, query, results: [], message: "No relevant results found." }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
|
||
// Фильтрация результатов по scopes.formIds:
|
||
// entity 'form' — по id формы; 'task' — по форме задачи;
|
||
// 'task_message' — по форме родительской задачи (metadata.taskId).
|
||
// Задел под scopes.tableIds: сущности справочников/data tables в выдаче
|
||
// сейчас не встречаются; при их появлении здесь же применять scopes.tableIds.
|
||
let filteredResults = results;
|
||
if (scopes.formIds !== null) {
|
||
const resolved = await Promise.all(results.map(async (r) => {
|
||
const metaFormId = (r.metadata as Record<string, unknown> | null)?.formId;
|
||
if (typeof metaFormId === 'number') return { r, formId: metaFormId };
|
||
if (r.entityType === 'form') return { r, formId: r.entityId as number | null };
|
||
if (r.entityType === 'task') {
|
||
const t = await storage.getTask(r.entityId, organizationId).catch(() => null);
|
||
return { r, formId: t?.formId ?? null };
|
||
}
|
||
if (r.entityType === 'task_message') {
|
||
const metaTaskId = (r.metadata as Record<string, unknown> | null)?.taskId;
|
||
if (typeof metaTaskId !== 'number') return { r, formId: null };
|
||
const t = await storage.getTask(metaTaskId, organizationId).catch(() => null);
|
||
return { r, formId: t?.formId ?? null };
|
||
}
|
||
return { r, formId: null };
|
||
}));
|
||
// Результаты без определяемой формы при ограниченном formIds не показываем
|
||
filteredResults = resolved
|
||
.filter((x) => x.formId !== null && isFormAllowed(x.formId))
|
||
.map((x) => x.r);
|
||
}
|
||
|
||
// Enrich results with human-readable details
|
||
const enriched = await Promise.all(filteredResults.map(async (r) => {
|
||
const base = {
|
||
entityType: r.entityType,
|
||
entityId: r.entityId,
|
||
score: Math.round(r.score * 1000) / 1000,
|
||
content: r.content.length > 300 ? r.content.slice(0, 300) + "…" : r.content,
|
||
metadata: r.metadata,
|
||
};
|
||
|
||
if (r.entityType === "task") {
|
||
const task = await storage.getTask(r.entityId, organizationId).catch(() => null);
|
||
return { ...base, task_title: task?.title ?? null };
|
||
}
|
||
if (r.entityType === "form") {
|
||
const form = await storage.getForm(r.entityId, organizationId).catch(() => null);
|
||
return { ...base, form_name: form?.name ?? null };
|
||
}
|
||
return base;
|
||
}));
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, query, results: enriched }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return {
|
||
content: [{ type: "text" as const, text: `semantic_search error: ${msg}` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
// reindex_organization
|
||
register(
|
||
"reindex_organization",
|
||
{
|
||
title: "Reindex Organization",
|
||
description:
|
||
"Trigger a full reindex of all organization data for RAG/semantic search. " +
|
||
"This rebuilds the vector index for forms, tasks, and chat messages. " +
|
||
"The operation runs in the background and may take several minutes for large organizations. " +
|
||
"Parameters: entity_types (optional) — which types to reindex ('form', 'task', 'task_message'); default is all.",
|
||
inputSchema: {
|
||
entity_types: z
|
||
.array(z.enum(["form", "task", "task_message"]))
|
||
.optional()
|
||
.describe("Types to reindex (default: all)"),
|
||
},
|
||
},
|
||
async ({ entity_types }) => {
|
||
try {
|
||
const types = (entity_types ?? ["form", "task", "task_message"]) as EntityType[];
|
||
|
||
const { reindexOrganization } = await import("./services/embedding.service");
|
||
|
||
// Run in background
|
||
reindexOrganization(storage, organizationId, types).catch((err) => {
|
||
console.error("[RAG MCP] Reindex error:", err);
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
message: `Reindex started for entity types: ${types.join(", ")}. This runs in the background.`,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return {
|
||
content: [{ type: "text" as const, text: `reindex_organization error: ${msg}` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
// get_organization_context
|
||
register(
|
||
"get_organization_context",
|
||
{
|
||
title: "Get Organization Context",
|
||
description:
|
||
"Returns a structured snapshot of the organization for AI assistants: list of forms with fields and statuses, " +
|
||
"task counts by status, recent tasks, and recent chat messages. " +
|
||
"Use this tool first when the user asks about the CRM structure, workflows, or overall organization state.",
|
||
inputSchema: {
|
||
include_recent_tasks: z.boolean().optional().describe("Include recent tasks for each form (default true)"),
|
||
include_recent_messages: z.boolean().optional().describe("Include recent chat messages (default true)"),
|
||
recent_tasks_per_form: z.number().int().min(0).max(50).optional().describe("Number of recent tasks per form (default 5)"),
|
||
recent_messages_limit: z.number().int().min(0).max(100).optional().describe("Total number of recent messages (default 20)"),
|
||
},
|
||
},
|
||
async ({ include_recent_tasks, include_recent_messages, recent_tasks_per_form, recent_messages_limit }) => {
|
||
try {
|
||
const allForms = await storage.getFormsByOrganization(organizationId);
|
||
// Фильтруем выдачу по scopes.formIds (null = все формы)
|
||
const forms = allForms.filter((f) => isFormAllowed(f.id));
|
||
const formContexts = await Promise.all(
|
||
forms.map(async (form) => {
|
||
const [fields, statuses, counts] = await Promise.all([
|
||
storage.getFormFields(form.id, organizationId),
|
||
storage.getFormStatuses(form.id, organizationId),
|
||
storage.getTasksCountByForm(form.id),
|
||
]);
|
||
const statusCounts = await storage.getTaskCountsByStatus(form.id, organizationId).catch(() => []);
|
||
const recentTasks = include_recent_tasks !== false
|
||
? (await storage.getTasksByForm(form.id, organizationId)).slice(0, recent_tasks_per_form ?? 5)
|
||
: [];
|
||
return {
|
||
id: form.id,
|
||
name: form.name,
|
||
description: form.description,
|
||
ai_summary: form.aiSummary,
|
||
fields: fields.map(f => ({ id: f.id, code: f.code, name: f.name, type: f.type, required: f.isRequired })),
|
||
statuses: statuses.map(s => ({ id: s.id, name: s.name, color: s.color, is_initial: s.isInitial, is_final: s.isFinal })),
|
||
task_counts: {
|
||
total: counts.total,
|
||
active: counts.active,
|
||
completed: counts.completed,
|
||
by_status: statusCounts.map(s => ({ status_id: s.statusId, name: s.name, count: s.count })),
|
||
},
|
||
recent_tasks: recentTasks.map(t => ({
|
||
id: t.id,
|
||
title: t.title,
|
||
status_id: t.currentStatusId,
|
||
assigned_to: t.assignedTo,
|
||
created_at: t.createdAt,
|
||
updated_at: t.updatedAt,
|
||
})),
|
||
};
|
||
})
|
||
);
|
||
|
||
let recentMessages: any[] = [];
|
||
if (include_recent_messages !== false) {
|
||
const allTasks = ((await storage.getTasksByOrganization(organizationId, { limit: 200, minimal: false })) as Task[])
|
||
// Сообщения только из задач разрешённых форм
|
||
.filter((t) => isFormAllowed(t.formId));
|
||
const taskTitleMap = new Map(allTasks.map(t => [t.id, t.title]));
|
||
const messageChunks = await Promise.all(
|
||
allTasks.slice(0, 50).map(t => storage.getTaskMessages(t.id, organizationId).catch(() => []))
|
||
);
|
||
recentMessages = messageChunks
|
||
.flat()
|
||
.filter((m: any) => m.messageType === 'comment')
|
||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||
.slice(0, recent_messages_limit ?? 20)
|
||
.map((m: any) => ({
|
||
id: m.id,
|
||
task_id: m.taskId,
|
||
task_title: taskTitleMap.get(m.taskId) ?? null,
|
||
author: m.author ? formatUserName(m.author) : (m.authorId ? `user:${m.authorId}` : 'bot'),
|
||
message: m.message,
|
||
created_at: m.createdAt,
|
||
}));
|
||
}
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
organization_id: organizationId,
|
||
forms: formContexts,
|
||
recent_messages: recentMessages,
|
||
note: "Use semantic_search for detailed natural-language lookups across forms, tasks, and messages.",
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return {
|
||
content: [{ type: "text" as const, text: `get_organization_context error: ${msg}` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
// list_field_templates
|
||
register(
|
||
"list_field_templates",
|
||
{
|
||
title: "List Field Templates",
|
||
description: "List all field templates defined in this organization. Use their IDs when calling add_field_template_to_form or add_field_templates_to_table_tab.",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const fields = await storage.getFieldTemplates(organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
fields.map((f) => ({
|
||
id: f.id,
|
||
code: f.code,
|
||
name: f.name,
|
||
type: f.type,
|
||
isRequired: f.isRequired,
|
||
placeholder: f.placeholder,
|
||
description: f.description,
|
||
options: f.options,
|
||
})),
|
||
null, 2
|
||
),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_field_template
|
||
register(
|
||
"create_field_template",
|
||
{
|
||
title: "Create Field Template",
|
||
description:
|
||
"Create a new field template for this organization. " +
|
||
"Field templates are reusable presets that can later be inserted into any form via add_field_template_to_form. " +
|
||
"The code must be unique within the organization and contain only lowercase letters, digits, and underscores.",
|
||
inputSchema: {
|
||
code: z.string().min(1).regex(/^[a-z0-9_]+$/).describe("Unique machine code (lowercase letters, digits, underscores only, e.g. 'phone_mobile')"),
|
||
name: z.string().min(1).describe("Human-readable field label (e.g. 'Мобильный телефон')"),
|
||
type: z.enum(GLOBAL_FIELD_TYPES as [string, ...string[]]).describe("Field type"),
|
||
is_required: z.boolean().optional().describe("Whether the field is required (default false)"),
|
||
placeholder: z.string().optional().describe("Placeholder hint text shown in empty inputs"),
|
||
description: z.string().optional().describe("Internal description for admins"),
|
||
options: z.array(z.string()).optional().describe("For type='select': list of option values"),
|
||
default_value: z.string().optional().describe("Default value pre-filled when field is used"),
|
||
},
|
||
},
|
||
async ({ code, name, type, is_required, placeholder, description, options, default_value }) => {
|
||
try {
|
||
const existing = await storage.getFieldTemplateByCode(code, organizationId);
|
||
if (existing) {
|
||
return {
|
||
content: [{ type: "text" as const, text: `Field template with code '${code}' already exists (id: ${existing.id})` }],
|
||
isError: true,
|
||
};
|
||
}
|
||
|
||
const actor = await getActor();
|
||
|
||
const field = await storage.createFieldTemplate({
|
||
code,
|
||
name,
|
||
type,
|
||
isRequired: is_required ?? false,
|
||
placeholder: placeholder ?? null,
|
||
description: description ?? null,
|
||
options: options ? options : null,
|
||
defaultValue: default_value ?? null,
|
||
validationRules: null,
|
||
organizationId,
|
||
createdBy: actor.user.id,
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, field: { id: field.id, code: field.code, name: field.name, type: field.type } }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return { content: [{ type: "text" as const, text: `create_field_template error: ${msg}` }], isError: true };
|
||
}
|
||
}
|
||
);
|
||
|
||
// add_field_template_to_form
|
||
register(
|
||
"add_field_template_to_form",
|
||
{
|
||
title: "Add Field Template to Form",
|
||
description:
|
||
"Insert a field template into a form as a regular field. " +
|
||
"The field code will be ft_{fieldTemplateId} — ensures traceability to the source template. " +
|
||
"If the field already exists in the form, returns the existing one without duplicating. " +
|
||
"Use list_field_templates to get available field template IDs.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The numeric ID of the form to add the field to"),
|
||
field_template_id: z.number().int().describe("The ID of the field template to insert"),
|
||
position: z.number().int().optional().describe("Display order position (0-based). Omit to append after existing fields."),
|
||
tab_id: z.number().int().optional().describe("Optional: place the field inside a specific form tab (tab ID)"),
|
||
},
|
||
},
|
||
async ({ form_id, field_template_id, position, tab_id }) => {
|
||
try {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const [form, fieldTemplate] = await Promise.all([
|
||
storage.getForm(form_id, organizationId),
|
||
storage.getFieldTemplate(field_template_id, organizationId),
|
||
]);
|
||
if (!form) {
|
||
return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
}
|
||
if (!fieldTemplate) {
|
||
return { content: [{ type: "text" as const, text: "Field template not found" }], isError: true };
|
||
}
|
||
|
||
const fieldCode = `ft_${field_template_id}`;
|
||
const existingFields = await storage.getFormFields(form_id, organizationId);
|
||
const existing = existingFields.find((f) => f.code === fieldCode);
|
||
if (existing) {
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, field: { id: existing.id, code: existing.code, name: existing.name }, alreadyExists: true }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
|
||
const maxPos =
|
||
existingFields.length > 0 ? Math.max(...existingFields.map((f) => f.position)) : -1;
|
||
const targetPosition =
|
||
typeof position === "number" ? Math.max(0, position) : maxPos + 1;
|
||
|
||
if (typeof position === "number") {
|
||
const toShift = existingFields
|
||
.filter((f) => f.position >= targetPosition)
|
||
.sort((a, b) => b.position - a.position);
|
||
for (const f of toShift) {
|
||
await storage.updateFormField(f.id, form_id, organizationId, { position: f.position + 1 });
|
||
}
|
||
}
|
||
|
||
let resolvedTabId: number | null = null;
|
||
if (typeof tab_id === "number") {
|
||
const tab = await storage.getFormTab(tab_id, form_id, organizationId);
|
||
if (!tab) {
|
||
return { content: [{ type: "text" as const, text: `Tab ${tab_id} not found in form ${form_id}` }], isError: true };
|
||
}
|
||
resolvedTabId = tab_id;
|
||
}
|
||
|
||
const newField = await storage.createFormField({
|
||
formId: form_id,
|
||
tabId: resolvedTabId,
|
||
name: fieldTemplate.name,
|
||
code: fieldCode,
|
||
type: fieldTemplate.type,
|
||
isRequired: fieldTemplate.isRequired ?? false,
|
||
placeholder: fieldTemplate.placeholder ?? null,
|
||
defaultValue: fieldTemplate.defaultValue ?? null,
|
||
validationRules: fieldTemplate.validationRules ?? null,
|
||
options: fieldTemplate.options ?? null,
|
||
position: targetPosition,
|
||
linkedFormId: null,
|
||
buttonActionType: null,
|
||
buttonUrl: null,
|
||
buttonFormId: null,
|
||
companyAutofill: null,
|
||
taskRelationType: null,
|
||
maxFileCount: null,
|
||
maxFileSizeMB: null,
|
||
} as Parameters<typeof storage.createFormField>[0]);
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, field: { id: newField.id, code: newField.code, name: newField.name, position: newField.position }, alreadyExists: false }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return { content: [{ type: "text" as const, text: `add_field_template_to_form error: ${msg}` }], isError: true };
|
||
}
|
||
}
|
||
);
|
||
|
||
// get_form_tabs
|
||
register(
|
||
"get_form_tabs",
|
||
{
|
||
title: "Get Form Tabs",
|
||
description:
|
||
"List all tabs defined in a form. Returns tab IDs, names, types, and (for 'table' type tabs) " +
|
||
"the list of columns with their IDs and names. Use this to discover tabId before calling append_table_row.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The numeric ID of the form"),
|
||
},
|
||
},
|
||
async ({ form_id }) => {
|
||
try {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const form = await storage.getForm(form_id, organizationId);
|
||
if (!form) {
|
||
return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
}
|
||
const tabs = await storage.getFormTabs(form_id, organizationId);
|
||
type ColDef = { id: string; name: string; type?: string };
|
||
const result = tabs.map((t) => ({
|
||
id: t.id,
|
||
name: t.name,
|
||
type: t.type,
|
||
position: t.position,
|
||
...(t.type === "table" && Array.isArray(t.tableColumns)
|
||
? { columns: (t.tableColumns as ColDef[]).map((c) => ({ id: c.id, name: c.name, type: c.type })) }
|
||
: {}),
|
||
}));
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ form: { id: form.id, name: form.name }, tabs: result }, null, 2) }],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return { content: [{ type: "text" as const, text: `get_form_tabs error: ${msg}` }], isError: true };
|
||
}
|
||
}
|
||
);
|
||
|
||
// append_table_row
|
||
register(
|
||
"append_table_row",
|
||
{
|
||
title: "Append Row to Table Tab",
|
||
description:
|
||
"Add a new data row to a 'table' type tab of a specific task. " +
|
||
"Provide either tab_id (numeric) or tab_name (string, case-insensitive) to identify the tab. " +
|
||
"The data object maps column IDs (e.g. 'gf_12') to values. " +
|
||
"Use get_form_tabs to discover available tab IDs, names, and column IDs.",
|
||
inputSchema: {
|
||
task_id: z.number().int().describe("The numeric ID of the task"),
|
||
tab_id: z.number().int().optional().describe("Numeric ID of the table tab. Preferred over tab_name."),
|
||
tab_name: z.string().optional().describe("Name of the table tab (case-insensitive). Used when tab_id is not known."),
|
||
data: z.record(z.any()).describe("Row data: mapping of column ID to value (e.g. { 'gf_12': 'Иванов', 'gf_13': '79001234567' })"),
|
||
},
|
||
},
|
||
async ({ task_id, tab_id, tab_name, data }) => {
|
||
try {
|
||
if (tab_id === undefined && !tab_name) {
|
||
return { content: [{ type: "text" as const, text: "Provide either tab_id or tab_name" }], isError: true };
|
||
}
|
||
|
||
const task = await storage.getTask(task_id, organizationId);
|
||
if (!task) {
|
||
return { content: [{ type: "text" as const, text: "Task not found" }], isError: true };
|
||
}
|
||
// Таб резолвится через форму задачи — проверяем доступ к ней
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
let resolvedTabId: number;
|
||
if (typeof tab_id === "number") {
|
||
const tab = await storage.getFormTab(tab_id, task.formId, organizationId);
|
||
if (!tab) {
|
||
return { content: [{ type: "text" as const, text: `Tab ${tab_id} not found in form ${task.formId}` }], isError: true };
|
||
}
|
||
if (tab.type !== "table") {
|
||
return { content: [{ type: "text" as const, text: `Tab ${tab_id} is of type '${tab.type}', not 'table'` }], isError: true };
|
||
}
|
||
resolvedTabId = tab_id;
|
||
} else {
|
||
const tabs = await storage.getFormTabs(task.formId, organizationId);
|
||
const found = tabs.find((t) => t.type === "table" && t.name.toLowerCase() === tab_name!.toLowerCase());
|
||
if (!found) {
|
||
const tableTabNames = tabs.filter((t) => t.type === "table").map((t) => `"${t.name}" (id=${t.id})`).join(", ");
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: `Table tab named "${tab_name}" not found in form ${task.formId}. Available table tabs: ${tableTabNames || "none"}`,
|
||
}],
|
||
isError: true,
|
||
};
|
||
}
|
||
resolvedTabId = found.id;
|
||
}
|
||
|
||
const actor = await getActor();
|
||
|
||
const row = await storage.createRegularTableRow({
|
||
taskId: task_id,
|
||
tabId: resolvedTabId,
|
||
data: data ?? {},
|
||
createdBy: actor.user.id,
|
||
});
|
||
|
||
const tab = await storage.getFormTab(resolvedTabId, task.formId, organizationId);
|
||
const persistedData = row.data && typeof row.data === "object" && Object.keys(row.data).length > 0 ? row.data : null;
|
||
storage.addTaskAuditLog({
|
||
taskId: task_id,
|
||
organizationId,
|
||
action: "field.changed",
|
||
fieldName: `Таблица «${tab?.name || resolvedTabId}»: добавлена строка`,
|
||
oldValue: null,
|
||
newValue: persistedData,
|
||
...actorAudit(actor),
|
||
metadata: { source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error("Audit log error (MCP append_table_row):", e); });
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, row: { id: row.id, tabId: resolvedTabId, data: row.data } }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return { content: [{ type: "text" as const, text: `append_table_row error: ${msg}` }], isError: true };
|
||
}
|
||
}
|
||
);
|
||
|
||
// add_field_templates_to_table_tab
|
||
register(
|
||
"add_field_templates_to_table_tab",
|
||
{
|
||
title: "Add Field Templates to Table Tab",
|
||
description:
|
||
"Create a 'Simple Table' tab in a form with columns from field templates, or add missing columns to an existing table tab with the same name. " +
|
||
"Column ID = ft_{fieldTemplateId}. Already-present columns are skipped (idempotent). " +
|
||
"Use list_field_templates to get IDs, list_form_tabs (get_form_fields) to inspect existing tabs.",
|
||
inputSchema: {
|
||
form_id: z.number().int().describe("The numeric ID of the form"),
|
||
tab_name: z.string().min(1).describe("Name of the table tab to create or update (matched case-insensitively)"),
|
||
field_template_ids: z.array(z.number().int()).min(1).describe("IDs of field templates to add as table columns"),
|
||
},
|
||
},
|
||
async ({ form_id, tab_name, field_template_ids }) => {
|
||
try {
|
||
if (!isFormAllowed(form_id)) return formDenied(form_id);
|
||
const [form, existingTabs] = await Promise.all([
|
||
storage.getForm(form_id, organizationId),
|
||
storage.getFormTabs(form_id, organizationId),
|
||
]);
|
||
if (!form) {
|
||
return { content: [{ type: "text" as const, text: "Form not found" }], isError: true };
|
||
}
|
||
|
||
const uniqueFieldTemplateIds = [...new Set(field_template_ids)];
|
||
const fieldTemplatesList = await Promise.all(
|
||
uniqueFieldTemplateIds.map((id) => storage.getFieldTemplate(id, organizationId))
|
||
);
|
||
const validFieldTemplates = fieldTemplatesList.filter(Boolean);
|
||
if (validFieldTemplates.length === 0) {
|
||
return { content: [{ type: "text" as const, text: "None of the specified field template IDs were found" }], isError: true };
|
||
}
|
||
|
||
const trimmedName = tab_name.trim();
|
||
let tab = existingTabs.find(
|
||
(t) => t.name.toLowerCase() === trimmedName.toLowerCase() && t.type === "table"
|
||
);
|
||
let isNew = false;
|
||
|
||
if (!tab) {
|
||
const tabCode = `gft_${trimmedName
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]/g, "_")
|
||
.replace(/_+/g, "_")
|
||
.slice(0, 70)}_${Date.now()}`;
|
||
const maxPosition =
|
||
existingTabs.length > 0 ? Math.max(...existingTabs.map((t) => t.position)) + 1 : 0;
|
||
tab = await storage.createFormTab({
|
||
formId: form_id,
|
||
name: trimmedName,
|
||
code: tabCode,
|
||
type: "table",
|
||
position: maxPosition,
|
||
tableColumns: [],
|
||
} as Parameters<typeof storage.createFormTab>[0]);
|
||
isNew = true;
|
||
}
|
||
|
||
type ColDef = { id: string; name: string; type: string; width?: number };
|
||
const currentColumns: ColDef[] = Array.isArray(tab.tableColumns)
|
||
? (tab.tableColumns as ColDef[])
|
||
: [];
|
||
const existingColIds = new Set(currentColumns.map((c) => c.id));
|
||
|
||
const newColumns: ColDef[] = validFieldTemplates
|
||
.filter((ft) => !existingColIds.has(`ft_${ft!.id}`))
|
||
.filter((ft) => TABLE_FIELD_TYPES.includes(ft!.type))
|
||
.map((ft) => ({
|
||
id: `ft_${ft!.id}`,
|
||
name: ft!.name,
|
||
type: ft!.type,
|
||
}));
|
||
|
||
let updatedTab = tab;
|
||
if (newColumns.length > 0) {
|
||
updatedTab = await storage.updateFormTab(tab.id, form_id, organizationId, {
|
||
tableColumns: [...currentColumns, ...newColumns],
|
||
});
|
||
}
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
tab: { id: updatedTab.id, name: updatedTab.name, type: updatedTab.type },
|
||
isNew,
|
||
addedColumns: newColumns.map((c) => ({ id: c.id, name: c.name })),
|
||
skippedColumns: validFieldTemplates.length - newColumns.length,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return { content: [{ type: "text" as const, text: `add_field_templates_to_table_tab error: ${msg}` }], isError: true };
|
||
}
|
||
}
|
||
);
|
||
|
||
// ── Справочники (Data Tables / Directories) ──────────────────────────────
|
||
|
||
// list_directories
|
||
register(
|
||
"list_directories",
|
||
{
|
||
title: "List Directories",
|
||
description: "List all directories (data tables / справочники) in the organization with their columns and row counts",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const all = await storage.getDataTablesByOrganization(organizationId);
|
||
// Фильтруем выдачу по scopes.tableIds (null = все справочники)
|
||
const allowed = all.filter((t) => isTableAllowed(t.id));
|
||
// Количество строк по всем справочникам — одним GROUP BY запросом (без N+1)
|
||
const rowCounts = await storage.getDataTableRowCounts(organizationId);
|
||
const result = allowed.map((t) => ({
|
||
id: t.id,
|
||
name: t.name,
|
||
description: t.description,
|
||
columns: t.columns,
|
||
rowCount: rowCounts.get(t.id) ?? 0,
|
||
}));
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_directory
|
||
register(
|
||
"get_directory",
|
||
{
|
||
title: "Get Directory",
|
||
description: "Get a directory (data table) by ID: columns, tree settings and subdirectories (linked child tables)",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
},
|
||
},
|
||
async ({ tableId }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
const links = await storage.getDataTableLinks(tableId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
id: table.id,
|
||
name: table.name,
|
||
description: table.description,
|
||
columns: table.columns,
|
||
treeColumnIndex: table.treeColumnIndex,
|
||
treeDisplayColumnIndex: table.treeDisplayColumnIndex,
|
||
treeColumns: table.treeColumns,
|
||
treeDisplayColumns: table.treeDisplayColumns,
|
||
visibility: table.visibility,
|
||
createdAt: table.createdAt,
|
||
updatedAt: table.updatedAt,
|
||
subdirectories: links.map((l) => ({
|
||
id: l.id,
|
||
parentTableId: l.parentTableId,
|
||
childTableId: l.childTableId,
|
||
parentColumnIndex: l.parentColumnIndex,
|
||
childColumnIndex: l.childColumnIndex,
|
||
parentRowIds: l.parentRowIds,
|
||
childTable: l.childTable ?? null,
|
||
})),
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// list_directory_rows
|
||
register(
|
||
"list_directory_rows",
|
||
{
|
||
title: "List Directory Rows",
|
||
description:
|
||
"List rows of a directory (data table). " +
|
||
"mode='flat' (default): paginated flat list with optional filters/search/sort. " +
|
||
"mode='tree': hierarchical tree (grouped by treeColumns or by parentId hierarchy, as in the web UI); " +
|
||
"in tree mode filters use exact match, limit/offset/sort are ignored. " +
|
||
"values are positional: values[i] corresponds to columns[i] of the directory.",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
filters: z.record(z.string(), z.string()).optional().describe("Column index (as string key) → substring filter; flat mode: 'includes' case-insensitive, tree mode: exact match"),
|
||
search: z.string().optional().describe("Substring search across all values (case-insensitive)"),
|
||
sortColumn: z.number().int().optional().describe("Column index to sort by (flat mode only)"),
|
||
sortDirection: z.enum(["asc", "desc"]).optional().describe("Sort direction (flat mode only)"),
|
||
limit: z.number().int().min(1).max(1000).optional().describe("Max rows to return (default 100, max 1000; flat mode only)"),
|
||
offset: z.number().int().min(0).optional().describe("Rows to skip (default 0; flat mode only)"),
|
||
mode: z.enum(["flat", "tree"]).optional().describe("'flat' (default) or 'tree'"),
|
||
},
|
||
},
|
||
async ({ tableId, filters, search, sortColumn, sortDirection, limit, offset, mode }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
|
||
// tree-режим: дерево строится из всех строк (иерархия parent_id / treeColumns),
|
||
// поэтому здесь осознанно полная выборка — пагинация в дереве неприменима.
|
||
if (mode === "tree") {
|
||
const rows = await storage.getDataTableRows(tableId, organizationId);
|
||
const tree = buildDataTableTree(table, rows, { filters, search });
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(tree, null, 2) }],
|
||
};
|
||
}
|
||
|
||
// flat-режим: фильтры/поиск/сортировка/пагинация на уровне SQL
|
||
let numericFilters: Record<number, string> | undefined;
|
||
if (filters) {
|
||
numericFilters = {};
|
||
for (const [col, val] of Object.entries(filters)) {
|
||
const ci = parseInt(col);
|
||
if (!isNaN(ci) && val) numericFilters[ci] = val;
|
||
}
|
||
}
|
||
|
||
const { rows, total } = await storage.getDataTableRowsPaged(tableId, organizationId, {
|
||
filters: numericFilters,
|
||
search,
|
||
sortColumn,
|
||
sortDirection,
|
||
limit: limit ?? 100,
|
||
offset: offset ?? 0,
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
rows: rows.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })),
|
||
total,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_directory_row
|
||
register(
|
||
"get_directory_row",
|
||
{
|
||
title: "Get Directory Row",
|
||
description: "Get a single row of a directory (data table) by ID",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rowId: z.number().int().describe("The numeric ID of the row"),
|
||
},
|
||
},
|
||
async ({ tableId, rowId }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||
if (!row) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ id: row.id, tableId: row.tableId, parentId: row.parentId, values: row.values, position: row.position }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// get_directory_column_values
|
||
register(
|
||
"get_directory_column_values",
|
||
{
|
||
title: "Get Directory Column Values",
|
||
description: "Get unique values of a directory column (autocomplete for filters), optionally filtered by a search substring",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
column: z.number().int().describe("Column index (0-based)"),
|
||
search: z.string().optional().describe("Substring filter on values (case-insensitive)"),
|
||
limit: z.number().int().min(1).max(200).optional().describe("Max values to return (default 20)"),
|
||
},
|
||
},
|
||
async ({ tableId, column, search, limit }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
if (column < 0 || column >= (table.columns?.length ?? 0)) {
|
||
return directoryError(`Колонка ${column} вне диапазона (в справочнике ${table.columns?.length ?? 0} колонок)`);
|
||
}
|
||
const rows = await storage.getDataTableRows(tableId, organizationId);
|
||
const q = (search ?? '').toLowerCase();
|
||
const seen = new Set<string>();
|
||
const values: string[] = [];
|
||
for (const row of rows) {
|
||
const rv = Array.isArray(row.values) ? row.values : [];
|
||
const val = String(rv[column] || '').trim();
|
||
if (!val || seen.has(val)) continue;
|
||
if (q && !val.toLowerCase().includes(q)) continue;
|
||
seen.add(val);
|
||
values.push(val);
|
||
if (values.length >= (limit ?? 20)) break;
|
||
}
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ column, values }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_directory_row
|
||
register(
|
||
"create_directory_row",
|
||
{
|
||
title: "Create Directory Row",
|
||
description:
|
||
"Create a new row in a directory (data table). values are positional: values[i] corresponds to columns[i]. " +
|
||
"The row is appended at the end of its siblings (same parentId). " +
|
||
"Use list_directory_rows to discover existing row IDs for parentId.",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
values: z.array(z.string()).describe("Positional values: values[i] = columns[i]. Extra values are truncated, missing ones filled with empty strings."),
|
||
parentId: z.number().int().nullable().optional().describe("Parent row ID for hierarchical directories (optional, null = root)"),
|
||
},
|
||
},
|
||
async ({ tableId, values, parentId }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
|
||
const columnCount = table.columns?.length ?? values.length;
|
||
const normalizedValues = normalizeRowValues(values, columnCount);
|
||
const targetParentId = parentId ?? null;
|
||
|
||
const existingRows = await storage.getDataTableRows(tableId, organizationId);
|
||
if (targetParentId !== null && !existingRows.some((r) => r.id === targetParentId)) {
|
||
return directoryError(`Родительская строка ${targetParentId} не найдена в справочнике ${tableId}`);
|
||
}
|
||
|
||
// Ставим строку в конец среди siblings с тем же parentId
|
||
const siblings = existingRows.filter((r) => (r.parentId ?? null) === targetParentId);
|
||
const position = siblings.length > 0 ? Math.max(...siblings.map((s) => s.position)) + 1 : 0;
|
||
|
||
const row = await storage.createDataTableRow({
|
||
tableId,
|
||
values: normalizedValues,
|
||
parentId: targetParentId,
|
||
position,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, row: { id: row.id, tableId: row.tableId, parentId: row.parentId, values: row.values, position: row.position } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// bulk_create_directory_rows
|
||
register(
|
||
"bulk_create_directory_rows",
|
||
{
|
||
title: "Bulk Create Directory Rows",
|
||
description:
|
||
"Create multiple rows in a directory (data table) at once. values are positional: values[i] = columns[i]. " +
|
||
"Positions are assigned sequentially at the end of each parent group.",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rows: z.array(z.object({
|
||
values: z.array(z.string()).describe("Positional values: values[i] = columns[i]"),
|
||
parentId: z.number().int().nullable().optional().describe("Parent row ID (optional, null = root)"),
|
||
})).min(1).describe("Rows to create"),
|
||
},
|
||
},
|
||
async ({ tableId, rows: inputRows }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
|
||
const columnCount = table.columns?.length ?? 0;
|
||
const existingRows = await storage.getDataTableRows(tableId, organizationId);
|
||
const existingIds = new Set(existingRows.map((r) => r.id));
|
||
for (const r of inputRows) {
|
||
if (r.parentId != null && !existingIds.has(r.parentId)) {
|
||
return directoryError(`Родительская строка ${r.parentId} не найдена в справочнике ${tableId}`);
|
||
}
|
||
}
|
||
|
||
// Позиции: продолжаем от максимальной позиции среди siblings каждой группы parentId
|
||
const nextPos = new Map<string, number>();
|
||
for (const r of existingRows) {
|
||
const key = String(r.parentId ?? 'root');
|
||
nextPos.set(key, Math.max(nextPos.get(key) ?? -1, r.position));
|
||
}
|
||
const payload = inputRows.map((r) => {
|
||
const key = String(r.parentId ?? 'root');
|
||
const position = (nextPos.get(key) ?? -1) + 1;
|
||
nextPos.set(key, position);
|
||
return {
|
||
values: normalizeRowValues(r.values, columnCount),
|
||
parentId: r.parentId ?? null,
|
||
position,
|
||
};
|
||
});
|
||
|
||
const created = await storage.bulkCreateDataTableRows(tableId, payload);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
createdCount: created.length,
|
||
rows: created.map((r) => ({ id: r.id, parentId: r.parentId, values: r.values, position: r.position })),
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_directory
|
||
register(
|
||
"create_directory",
|
||
{
|
||
title: "Create Directory",
|
||
description: "Create a new directory (data table / справочник) with the given columns",
|
||
inputSchema: {
|
||
name: z.string().min(1).describe("Directory name"),
|
||
description: z.string().optional().describe("Optional description"),
|
||
columns: z.array(z.object({
|
||
name: z.string().min(1).describe("Column name"),
|
||
type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']).optional().describe("Column type (default 'text')"),
|
||
options: z.array(z.string()).optional().describe("For 'select' type: list of options"),
|
||
isRequired: z.boolean().optional().describe("Whether the column is required"),
|
||
})).min(1).describe("Columns definition (at least one)"),
|
||
},
|
||
},
|
||
async ({ name, description, columns }) => {
|
||
const actor = await getActor();
|
||
|
||
const table = await storage.createDataTable({
|
||
name,
|
||
description: description ?? null,
|
||
columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })),
|
||
organizationId,
|
||
createdBy: actor.user.id,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, directory: { id: table.id, name: table.name, description: table.description, columns: table.columns } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_directory
|
||
register(
|
||
"update_directory",
|
||
{
|
||
title: "Update Directory",
|
||
description: "Update directory (data table) properties: name, description, columns, tree settings",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
name: z.string().min(1).optional().describe("New name"),
|
||
description: z.string().nullable().optional().describe("New description (null to clear)"),
|
||
columns: z.array(z.object({
|
||
name: z.string().min(1),
|
||
type: z.enum(['text', 'number', 'date', 'checkbox', 'select', 'user', 'datetime']).optional(),
|
||
options: z.array(z.string()).optional(),
|
||
isRequired: z.boolean().optional(),
|
||
})).min(1).optional().describe("New columns definition. WARNING: values are positional — changing column order/count shifts existing row values."),
|
||
treeColumns: z.array(z.number().int()).nullable().optional().describe("Column indexes for tree grouping (null to clear)"),
|
||
treeDisplayColumns: z.array(z.number().int()).nullable().optional().describe("Column indexes used for tree node labels (null to clear)"),
|
||
},
|
||
},
|
||
async ({ tableId, name, description, columns, treeColumns, treeDisplayColumns }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = name;
|
||
if (description !== undefined) updates.description = description;
|
||
if (columns !== undefined) updates.columns = columns.map((c) => ({ ...c, type: c.type ?? 'text' }));
|
||
if (treeColumns !== undefined) updates.treeColumns = treeColumns;
|
||
if (treeDisplayColumns !== undefined) updates.treeDisplayColumns = treeDisplayColumns;
|
||
if (Object.keys(updates).length === 0) {
|
||
return directoryError("Не переданы данные для обновления");
|
||
}
|
||
|
||
const updated = await storage.updateDataTable(tableId, organizationId, updates);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, directory: { id: updated.id, name: updated.name, description: updated.description, columns: updated.columns, treeColumns: updated.treeColumns, treeDisplayColumns: updated.treeDisplayColumns } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_directory
|
||
register(
|
||
"delete_directory",
|
||
{
|
||
title: "Delete Directory",
|
||
description: "Delete a directory (data table). Soft-delete: rows are kept in the database but the directory disappears from the UI and API.",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table) to delete"),
|
||
},
|
||
},
|
||
async ({ tableId }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
await storage.deleteDataTable(tableId, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: tableId, name: table.name } }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// update_directory_row
|
||
register(
|
||
"update_directory_row",
|
||
{
|
||
title: "Update Directory Row",
|
||
description: "Update values of a directory row. values are positional and replace the whole row: values[i] = columns[i].",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rowId: z.number().int().describe("The numeric ID of the row"),
|
||
values: z.array(z.string()).describe("New positional values (full replacement). Extra values are truncated, missing ones filled with empty strings."),
|
||
},
|
||
},
|
||
async ({ tableId, rowId, values }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
const existing = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||
if (!existing) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||
|
||
const columnCount = table.columns?.length ?? values.length;
|
||
const row = await storage.updateDataTableRow(rowId, tableId, organizationId, {
|
||
values: normalizeRowValues(values, columnCount),
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, row: { id: row.id, parentId: row.parentId, values: row.values, position: row.position } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_directory_row
|
||
register(
|
||
"delete_directory_row",
|
||
{
|
||
title: "Delete Directory Row",
|
||
description: "Delete a directory row (soft-delete). Its child rows are moved to the root (parentId = null).",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rowId: z.number().int().describe("The numeric ID of the row to delete"),
|
||
},
|
||
},
|
||
async ({ tableId, rowId }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const existing = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||
if (!existing) return directoryError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||
await storage.deleteDataTableRow(rowId, tableId, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: rowId, tableId } }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// move_directory_row
|
||
register(
|
||
"move_directory_row",
|
||
{
|
||
title: "Move Directory Row",
|
||
description:
|
||
"Move a directory row to a new parent and/or position. " +
|
||
"position is the index among siblings with the same parentId (0-based, clamped). " +
|
||
"Cycle protection: a row cannot be moved into itself or its descendant.",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rowId: z.number().int().describe("The numeric ID of the row to move"),
|
||
parentId: z.number().int().nullable().describe("New parent row ID (null = root)"),
|
||
position: z.number().int().min(0).describe("Position among siblings with the same parentId (0-based)"),
|
||
},
|
||
},
|
||
async ({ tableId, rowId, parentId, position }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return directoryError(`Справочник ${tableId} не найден`);
|
||
try {
|
||
const row = await storage.moveDataTableRow(rowId, tableId, organizationId, parentId, position);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, row: { id: row.id, parentId: row.parentId, values: row.values, position: row.position } }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return directoryError(`Ошибка перемещения строки: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// link_directories
|
||
register(
|
||
"link_directories",
|
||
{
|
||
title: "Link Directories",
|
||
description:
|
||
"Link a child directory to a parent directory (creates a subdirectory). " +
|
||
"Upsert: an existing link for the same parent/child pair is updated. " +
|
||
"parentColumnIndex/childColumnIndex define the join columns; parentRowIds limits the link to specific parent rows.",
|
||
inputSchema: {
|
||
parentTableId: z.number().int().describe("ID of the parent directory"),
|
||
childTableId: z.number().int().describe("ID of the child (sub)directory"),
|
||
parentColumnIndex: z.number().int().min(0).optional().describe("Join column index in the parent directory (default 0)"),
|
||
childColumnIndex: z.number().int().min(0).optional().describe("Join column index in the child directory (default 0)"),
|
||
parentRowIds: z.array(z.number().int()).optional().describe("Limit the link to these parent row IDs (default: all rows)"),
|
||
},
|
||
},
|
||
async ({ parentTableId, childTableId, parentColumnIndex, childColumnIndex, parentRowIds }) => {
|
||
if (!isTableAllowed(parentTableId)) return tableDenied(parentTableId);
|
||
if (!isTableAllowed(childTableId)) return tableDenied(childTableId);
|
||
const [parentTable, childTable] = await Promise.all([
|
||
storage.getDataTable(parentTableId, organizationId),
|
||
storage.getDataTable(childTableId, organizationId),
|
||
]);
|
||
if (!parentTable) return directoryError(`Родительский справочник ${parentTableId} не найден`);
|
||
if (!childTable) return directoryError(`Дочерний справочник ${childTableId} не найден`);
|
||
if (parentTableId === childTableId) return directoryError("Нельзя связать справочник сам с собой");
|
||
|
||
const link = await storage.createDataTableLink({
|
||
organizationId,
|
||
parentTableId,
|
||
childTableId,
|
||
parentColumnIndex,
|
||
childColumnIndex,
|
||
parentRowIds,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
link: {
|
||
id: link.id,
|
||
parentTableId: link.parentTableId,
|
||
childTableId: link.childTableId,
|
||
parentColumnIndex: link.parentColumnIndex,
|
||
childColumnIndex: link.childColumnIndex,
|
||
parentRowIds: link.parentRowIds,
|
||
},
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// unlink_directories
|
||
register(
|
||
"unlink_directories",
|
||
{
|
||
title: "Unlink Directories",
|
||
description: "Remove a link between directories by link ID. Use get_directory on the parent table to discover link IDs.",
|
||
inputSchema: {
|
||
linkId: z.number().int().describe("The numeric ID of the link to remove"),
|
||
},
|
||
},
|
||
async ({ linkId }) => {
|
||
const link = await storage.getDataTableLink(linkId, organizationId);
|
||
if (!link) return directoryError(`Связь ${linkId} не найдена`);
|
||
// Проверяем доступ к обеим связанным таблицам
|
||
if (!isTableAllowed(link.parentTableId)) return tableDenied(link.parentTableId);
|
||
if (!isTableAllowed(link.childTableId)) return tableDenied(link.childTableId);
|
||
await storage.deleteDataTableLink(linkId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, deleted: { id: linkId, parentTableId: link.parentTableId, childTableId: link.childTableId } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Сообщения задач ────────────────────────────────────────────────────────
|
||
|
||
// list_task_messages
|
||
register(
|
||
"list_task_messages",
|
||
{
|
||
title: "List Task Messages",
|
||
description: "List messages (chat) of a task, oldest first. Use afterId for incremental polling.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
afterId: z.number().int().optional().describe("Only messages with ID greater than this (incremental polling)"),
|
||
limit: z.number().int().min(1).max(500).optional().describe("Max messages to return (default 50)"),
|
||
},
|
||
},
|
||
async ({ taskId, afterId, limit }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const messages = await storage.getTaskMessages(taskId, organizationId, afterId);
|
||
const limited = messages.slice(0, limit ?? 50);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify(limited.map((m) => ({
|
||
id: m.id,
|
||
message: m.message,
|
||
messageType: m.messageType,
|
||
authorId: m.authorId,
|
||
author: m.author
|
||
? (`${m.author.firstName || ''} ${m.author.middleName || ''} ${m.author.lastName || ''}`.trim() || null)
|
||
: null,
|
||
bot: m.bot ?? null,
|
||
replyToMessageId: m.replyToMessageId,
|
||
mentionedUserIds: m.mentionedUserIds,
|
||
attachments: m.attachments,
|
||
createdAt: m.createdAt,
|
||
})), null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// send_task_message
|
||
register(
|
||
"send_task_message",
|
||
{
|
||
title: "Send Task Message",
|
||
description:
|
||
"Post a comment message to a task chat. Triggers the same side effects as the web UI: " +
|
||
"notifications, SSE events, webhooks, embeddings. The message is authored by the first admin user of the organization.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
content: z.string().min(1).describe("Message text"),
|
||
},
|
||
},
|
||
async ({ taskId, content }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
if (!content.trim()) return mcpError("Текст сообщения обязателен");
|
||
|
||
const actor = await getActor();
|
||
|
||
try {
|
||
const created = await sendTaskMessage({
|
||
task,
|
||
user: actor.bot ? undefined : actor.user,
|
||
botId: actor.bot?.id ?? null,
|
||
organizationId,
|
||
message: content,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
message: {
|
||
id: created.id,
|
||
taskId: created.taskId,
|
||
authorId: created.authorId,
|
||
message: created.message,
|
||
messageType: created.messageType,
|
||
createdAt: created.createdAt,
|
||
},
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
if (err instanceof SendTaskMessageError) return mcpError(err.message);
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка отправки сообщения: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// ── Исполнители задачи ─────────────────────────────────────────────────────
|
||
|
||
// get_task_assignees
|
||
register(
|
||
"get_task_assignees",
|
||
{
|
||
title: "Get Task Assignees",
|
||
description: "List assignees (исполнители) of a task with user id, name and email",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
},
|
||
},
|
||
async ({ taskId }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const assignees = await storage.getTaskAssignees(taskId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
taskId,
|
||
assignedTo: task.assignedTo,
|
||
assignees: assignees.map((a) => ({
|
||
userId: a.userId,
|
||
name: (`${a.user.firstName || ''} ${a.user.lastName || ''}`.trim()) || null,
|
||
email: a.user.email,
|
||
})),
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// set_task_assignees
|
||
register(
|
||
"set_task_assignees",
|
||
{
|
||
title: "Set Task Assignees",
|
||
description:
|
||
"Replace the full list of task assignees (исполнители). " +
|
||
"The legacy assignedTo field is synced to the first user in the list (or null when empty). " +
|
||
"Triggers auto-transitions, audit log entry and notifications, like the web UI.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
userIds: z.array(z.number().int()).describe("Full new list of assignee user IDs (empty array to clear all)"),
|
||
},
|
||
},
|
||
async ({ taskId, userIds }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const orgUserIds = new Set(orgUsers.map((u) => u.id));
|
||
const uniqueIds = [...new Set(userIds)];
|
||
const invalid = uniqueIds.filter((id) => !orgUserIds.has(id));
|
||
if (invalid.length > 0) {
|
||
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
|
||
}
|
||
|
||
const actor = await getActor();
|
||
|
||
// Полная замена списка: удаляем лишних, добавляем недостающих
|
||
const current = await storage.getTaskAssignees(taskId, organizationId);
|
||
const currentIds = current.map((a) => a.userId);
|
||
const toAdd = uniqueIds.filter((id) => !currentIds.includes(id));
|
||
const toRemove = currentIds.filter((id) => !uniqueIds.includes(id));
|
||
for (const id of toRemove) {
|
||
await storage.removeTaskAssignee(taskId, id, organizationId);
|
||
}
|
||
for (const id of toAdd) {
|
||
await storage.addTaskAssignee(taskId, id, organizationId);
|
||
}
|
||
|
||
// Синхронизация legacy-поля assignedTo (как в route assignees: первый из списка или null)
|
||
const newAssignedTo = uniqueIds[0] ?? null;
|
||
await storage.updateTask(taskId, organizationId, { assignedTo: newAssignedTo });
|
||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||
|
||
const names = uniqueIds
|
||
.map((id) => {
|
||
const u = orgUsers.find((x) => x.id === id);
|
||
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(id);
|
||
})
|
||
.join(', ');
|
||
storage.addTaskAuditLog({
|
||
taskId,
|
||
organizationId,
|
||
action: 'task.updated',
|
||
fieldName: 'Ответственные обновлены',
|
||
oldValue: null,
|
||
newValue: names || '(пусто)',
|
||
...actorAudit(actor),
|
||
metadata: { source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error('Audit log error (MCP set_task_assignees):', e); });
|
||
|
||
const refreshedTask = await storage.getTask(taskId, organizationId);
|
||
|
||
// Уведомление новому основному ответственному (если сменился)
|
||
if (refreshedTask && newAssignedTo && newAssignedTo !== task.assignedTo) {
|
||
notifyTaskAssigned(refreshedTask, newAssignedTo, actor.user.id, organizationId)
|
||
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
|
||
}
|
||
|
||
eventBus.publishEvent({
|
||
type: 'task_updated',
|
||
organizationId,
|
||
data: { taskId, formId: refreshedTask?.formId, task: refreshedTask },
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
taskId,
|
||
assignedTo: newAssignedTo,
|
||
assignees: uniqueIds,
|
||
added: toAdd,
|
||
removed: toRemove,
|
||
autoTransition: autoResult.changed,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Документы ──────────────────────────────────────────────────────────────
|
||
|
||
// list_document_templates
|
||
register(
|
||
"list_document_templates",
|
||
{
|
||
title: "List Document Templates",
|
||
description: "List document templates of the organization with their variables. Optionally filter by folder.",
|
||
inputSchema: {
|
||
folderId: z.number().int().optional().describe("Filter by template folder ID (optional)"),
|
||
},
|
||
},
|
||
async ({ folderId }) => {
|
||
const templateService = new DocumentTemplateService();
|
||
const templates = await templateService.list({ organizationId, folderId });
|
||
const result = await Promise.all(
|
||
templates.map(async (t) => {
|
||
const full = await templateService.getById(t.id, organizationId);
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
description: t.description,
|
||
folderId: t.folderId,
|
||
categoryId: t.categoryId,
|
||
formId: t.formId,
|
||
status: t.status,
|
||
variables: (full?.variables ?? []).map((v) => ({
|
||
id: v.id,
|
||
code: v.code,
|
||
label: v.label,
|
||
source: v.source,
|
||
})),
|
||
};
|
||
})
|
||
);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// generate_document
|
||
register(
|
||
"generate_document",
|
||
{
|
||
title: "Generate Document",
|
||
description:
|
||
"Generate a document (docx or pdf) from a template for a given task. " +
|
||
"Returns the generation ID and a download URL (requires user authorization to download).",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task to fill the template with"),
|
||
templateId: z.number().int().describe("The numeric ID of the document template"),
|
||
format: z.enum(["docx", "pdf"]).describe("Output format"),
|
||
},
|
||
},
|
||
async ({ taskId, templateId, format }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const actor = await getActor();
|
||
|
||
// Сервис генерации собирается так же, как в server/documents/routes.ts
|
||
const templateService = new DocumentTemplateService();
|
||
const dataResolution = new DataResolutionService();
|
||
const assetService = new AssetService();
|
||
const generationService = new DocumentGenerationService(templateService, dataResolution, assetService);
|
||
|
||
try {
|
||
const result = await generationService.generate({
|
||
templateId,
|
||
taskId,
|
||
organizationId,
|
||
userId: actor.user.id,
|
||
outputFormat: format,
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
generationId: result.generationId,
|
||
downloadUrl: `/api/documents/generations/${result.generationId}/download/${format}`,
|
||
pdfUrl: result.pdfUrl ?? null,
|
||
docxUrl: result.docxUrl ?? null,
|
||
status: result.status,
|
||
unresolvedVariables: result.unresolvedVariables,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка генерации документа: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// ── Файлы ──────────────────────────────────────────────────────────────────
|
||
|
||
// get_task_file
|
||
register(
|
||
"get_task_file",
|
||
{
|
||
title: "Get Task File",
|
||
description:
|
||
"Get a download URL for a file attached to a task (by fileKey from message attachments or file field values). " +
|
||
"Returns a presigned app-proxy URL (/api/files/<key>?presigned=<token>, valid ~5 minutes, no authorization required — prepend the CRM base URL, e.g. https://iistwin.ru).",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
fileKey: z.string().min(1).describe("File key (the part after /api/files/ or /uploads/ in the attachment URL)"),
|
||
},
|
||
},
|
||
async ({ taskId, fileKey }) => {
|
||
if (!fileKey || fileKey.includes('..') || fileKey.includes('/')) {
|
||
return mcpError("Неверный ключ файла");
|
||
}
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
// Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts).
|
||
// Если не отслеживается — догружаем по требованию (старые абсолютные URL и прочие
|
||
// неохваченные startup-backfill случаи, см. utils/file-tracking.ts).
|
||
let [upload] = await db
|
||
.select()
|
||
.from(fileUploads)
|
||
.where(eq(fileUploads.fileKey, fileKey))
|
||
.limit(1);
|
||
if (!upload) {
|
||
const tracked = await trackFileOnDemand(fileKey);
|
||
if (tracked) {
|
||
[upload] = await db
|
||
.select()
|
||
.from(fileUploads)
|
||
.where(eq(fileUploads.fileKey, fileKey))
|
||
.limit(1);
|
||
}
|
||
}
|
||
if (!upload) return mcpError("Файл не найден или не отслеживается");
|
||
if (upload.organizationId !== organizationId) {
|
||
return mcpError("Файл принадлежит другой организации");
|
||
}
|
||
|
||
// Проверка принадлежности файла именно этой задаче:
|
||
// напрямую (file_uploads.taskId), через вложения сообщений или через значения file-полей
|
||
let belongsToTask = upload.taskId === taskId;
|
||
if (!belongsToTask) {
|
||
const messages = await storage.getTaskMessages(taskId, organizationId);
|
||
belongsToTask = messages.some((m) =>
|
||
Array.isArray(m.attachments) &&
|
||
m.attachments.some((a) => typeof a?.url === 'string' && a.url.includes(fileKey))
|
||
);
|
||
}
|
||
if (!belongsToTask) {
|
||
const fieldValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||
// value может быть объектом/массивом (file-поля), а не строкой —
|
||
// сериализуем перед поиском ключа
|
||
belongsToTask = fieldValues.some((fv) => {
|
||
const raw = typeof fv.value === 'string' ? fv.value : JSON.stringify(fv.value ?? '');
|
||
return raw.includes(fileKey);
|
||
});
|
||
}
|
||
if (!belongsToTask) {
|
||
return mcpError(`Файл не относится к задаче ${taskId}`);
|
||
}
|
||
|
||
// Внешняя presigned-ссылка через прокси приложения (/api/files/<key>?presigned=<token>).
|
||
// Сырой presigned MinIO (http://minio:9000/...) снаружи docker-сети недоступен —
|
||
// для ботов/агентов он бесполезен, поэтому отдаём прокси-URL: работает и в
|
||
// S3-, и в локальном режиме, авторизация не требуется (~5 минут).
|
||
const token = generatePresignedToken(fileKey, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
fileKey,
|
||
originalName: upload.originalName,
|
||
downloadUrl: `/api/files/${fileKey}?presigned=${token}`,
|
||
type: "presigned",
|
||
expiresInSeconds: 300,
|
||
note: "Относительная ссылка — подставьте базовый URL CRM (например, https://iistwin.ru). Действует ~5 минут, авторизация не требуется.",
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Аудит задачи ───────────────────────────────────────────────────────────
|
||
|
||
// get_task_audit_log
|
||
register(
|
||
"get_task_audit_log",
|
||
{
|
||
title: "Get Task Audit Log",
|
||
description: "Get the audit log entries of a task (field changes, status changes, etc.), newest first",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
limit: z.number().int().min(1).max(500).optional().describe("Max entries to return (default 50)"),
|
||
},
|
||
},
|
||
async ({ taskId, limit }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const entries = await storage.getTaskAuditLog(taskId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify(entries.slice(0, limit ?? 50).map((e) => ({
|
||
id: e.id,
|
||
action: e.action,
|
||
fieldName: e.fieldName,
|
||
oldValue: e.oldValue,
|
||
newValue: e.newValue,
|
||
changedBy: e.changedBy,
|
||
changedByName: e.changedByName,
|
||
createdAt: e.createdAt,
|
||
})), null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Напоминания по задачам ─────────────────────────────────────────────────
|
||
|
||
// list_task_reminders
|
||
register(
|
||
"list_task_reminders",
|
||
{
|
||
title: "List Task Reminders",
|
||
description: "List pending (not yet sent) reminders of a task",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
},
|
||
},
|
||
async ({ taskId }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const reminders = await storage.getTaskReminders(taskId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify(reminders.map((r) => ({
|
||
id: r.id,
|
||
taskId: r.taskId,
|
||
remindAt: r.remindAt,
|
||
note: r.note,
|
||
recipients: r.recipients,
|
||
isSent: r.isSent,
|
||
createdByUserId: r.createdByUserId,
|
||
createdAt: r.createdAt,
|
||
})), null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// set_task_reminder
|
||
register(
|
||
"set_task_reminder",
|
||
{
|
||
title: "Set Task Reminder",
|
||
description: "Create a reminder for a task addressed to a single user. remindAt must be an ISO 8601 date-time.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
userId: z.number().int().describe("Recipient user ID (must belong to the organization)"),
|
||
remindAt: z.string().describe("Reminder date-time in ISO 8601 format"),
|
||
message: z.string().optional().describe("Optional note shown with the reminder"),
|
||
},
|
||
},
|
||
async ({ taskId, userId, remindAt, message }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const remindAtDate = new Date(remindAt);
|
||
if (isNaN(remindAtDate.getTime())) {
|
||
return mcpError("Неверный формат даты напоминания");
|
||
}
|
||
|
||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const recipient = orgUsers.find((u) => u.id === userId);
|
||
if (!recipient) {
|
||
return mcpError(`Пользователь ${userId} не принадлежит организации`);
|
||
}
|
||
const actor = await getActor();
|
||
|
||
const reminder = await storage.createTaskReminder({
|
||
taskId,
|
||
organizationId,
|
||
createdByUserId: actor.user.id,
|
||
remindAt: remindAtDate,
|
||
note: message ?? null,
|
||
recipients: [{ type: "user", userId }],
|
||
});
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, reminder }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// delete_task_reminder
|
||
register(
|
||
"delete_task_reminder",
|
||
{
|
||
title: "Delete Task Reminder",
|
||
description: "Delete a task reminder by its ID",
|
||
inputSchema: {
|
||
reminderId: z.number().int().describe("The numeric ID of the reminder"),
|
||
},
|
||
},
|
||
async ({ reminderId }) => {
|
||
// Напоминание → задача → проверка доступа к форме задачи
|
||
const [reminder] = await db
|
||
.select()
|
||
.from(taskReminders)
|
||
.where(and(eq(taskReminders.id, reminderId), eq(taskReminders.organizationId, organizationId)))
|
||
.limit(1);
|
||
if (!reminder) return mcpError(`Напоминание ${reminderId} не найдено`);
|
||
|
||
const task = await storage.getTask(reminder.taskId, organizationId);
|
||
if (task && !isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
await storage.deleteTaskReminder(reminderId, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: reminderId } }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Уведомления ────────────────────────────────────────────────────────────
|
||
|
||
// list_notifications
|
||
register(
|
||
"list_notifications",
|
||
{
|
||
title: "List Notifications",
|
||
description: "List notifications of a user, newest first",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("The numeric ID of the user"),
|
||
limit: z.number().int().min(1).max(200).optional().describe("Max notifications to return (default 50)"),
|
||
},
|
||
},
|
||
async ({ userId, limit }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
const items = await storage.getUserNotifications(userId, organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify(items.slice(0, limit ?? 50).map((n) => ({
|
||
id: n.id,
|
||
type: n.type,
|
||
title: n.title,
|
||
message: n.message,
|
||
taskId: n.taskId,
|
||
messageId: n.messageId,
|
||
isRead: n.isRead,
|
||
createdAt: n.createdAt,
|
||
})), null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// send_notification
|
||
register(
|
||
"send_notification",
|
||
{
|
||
title: "Send Notification",
|
||
description:
|
||
"Create an in-app notification for a user (type 'system') and push a realtime SSE event, " +
|
||
"so the user's notification badge updates immediately. " +
|
||
"Note: the schema has no link field — the optional link is appended to the message text.",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("Recipient user ID"),
|
||
title: z.string().min(1).describe("Notification title"),
|
||
message: z.string().min(1).describe("Notification text"),
|
||
link: z.string().optional().describe("Optional URL (appended to the message text)"),
|
||
},
|
||
},
|
||
async ({ userId, title, message, link }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
// В схеме user_notifications нет поля link — добавляем ссылку в текст
|
||
const notification = await storage.createUserNotification({
|
||
userId,
|
||
organizationId,
|
||
type: "system",
|
||
title,
|
||
message: link ? `${message}\n${link}` : message,
|
||
isRead: false,
|
||
});
|
||
// Realtime-обновление бейджа уведомлений у пользователя (как воркер напоминаний в index.ts)
|
||
publishNotificationSSE(userId, organizationId, { type: "system", notificationId: notification.id });
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, notification: { id: notification.id, userId, title: notification.title, isRead: notification.isRead, createdAt: notification.createdAt } }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// mark_notifications_read
|
||
register(
|
||
"mark_notifications_read",
|
||
{
|
||
title: "Mark Notifications Read",
|
||
description: "Mark a single notification (by notificationId) or all notifications of a user as read",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("The numeric ID of the user"),
|
||
notificationId: z.number().int().optional().describe("Notification ID to mark as read. Omit to mark ALL user notifications as read."),
|
||
},
|
||
},
|
||
async ({ userId, notificationId }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
if (notificationId !== undefined) {
|
||
const updated = await storage.markNotificationAsRead(notificationId, userId, organizationId);
|
||
if (!updated) return mcpError(`Уведомление ${notificationId} не найдено у пользователя ${userId}`);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: 1 }, null, 2) }],
|
||
};
|
||
}
|
||
await storage.markAllNotificationsAsRead(userId, organizationId);
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: "all" }, null, 2) }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Входящие (inbox) ───────────────────────────────────────────────────────
|
||
|
||
// get_inbox
|
||
register(
|
||
"get_inbox",
|
||
{
|
||
title: "Get Inbox",
|
||
description:
|
||
"Get the inbox of a user: uncompleted tasks requiring their attention " +
|
||
"(assigned, mentioned in unread notifications, or via task roles), with reasons and unread counts. " +
|
||
"Mirrors GET /api/home/inbox with default filters. total is computed after API-key form filtering.",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("The numeric ID of the user"),
|
||
limit: z.number().int().min(1).max(100).optional().describe("Max tasks to return (default 50)"),
|
||
},
|
||
},
|
||
async ({ userId, limit }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
const limitVal = Math.min(limit ?? 50, 100);
|
||
|
||
// SQL повторяет GET /api/home/inbox (home.routes.ts) с фильтрами по умолчанию:
|
||
// без search/status/assignee/overdue, сортировка t.updated_at DESC.
|
||
// Выбираем с запасом (500), затем фильтруем по scopes.formIds и режем до limit.
|
||
const result = await pool.query(`
|
||
WITH inbox_sources AS (
|
||
SELECT t.id AS task_id, 'assignee' AS reason, NULL::text AS role_name
|
||
FROM tasks t
|
||
WHERE t.organization_id = $1
|
||
AND t.assigned_to = $2
|
||
AND t.is_completed = false
|
||
|
||
UNION
|
||
|
||
SELECT ta.task_id, 'assignee' AS reason, NULL::text AS role_name
|
||
FROM task_assignees ta
|
||
JOIN tasks t ON t.id = ta.task_id AND t.organization_id = $1 AND t.is_completed = false
|
||
WHERE ta.user_id = $2 AND ta.organization_id = $1
|
||
|
||
UNION
|
||
|
||
-- Замещение: пользователь — заместитель отсутствующего исполнителя
|
||
SELECT ta_d.task_id, 'delegation' AS reason, NULL::text AS role_name
|
||
FROM task_assignees ta_d
|
||
JOIN tasks t_d ON t_d.id = ta_d.task_id AND t_d.organization_id = $1 AND t_d.is_completed = false
|
||
WHERE ta_d.user_id = $2 AND ta_d.organization_id = $1
|
||
AND ta_d.delegated_from_user_id IS NOT NULL
|
||
|
||
UNION
|
||
|
||
-- Замещение: задача ждёт ознакомления отсутствующего исполнителя
|
||
SELECT ta_r.task_id, 'delegation_review' AS reason, NULL::text AS role_name
|
||
FROM task_assignees ta_r
|
||
JOIN tasks t_r ON t_r.id = ta_r.task_id AND t_r.organization_id = $1 AND t_r.is_completed = false
|
||
WHERE ta_r.user_id = $2 AND ta_r.organization_id = $1
|
||
AND ta_r.pending_review = true
|
||
|
||
UNION
|
||
|
||
SELECT un.task_id, 'notification' AS reason, NULL::text
|
||
FROM user_notifications un
|
||
JOIN tasks t2 ON t2.id = un.task_id AND t2.organization_id = $1 AND t2.is_completed = false
|
||
WHERE un.organization_id = $1
|
||
AND un.user_id = $2
|
||
AND un.is_read = false
|
||
AND un.task_id IS NOT NULL
|
||
|
||
UNION
|
||
|
||
SELECT tr.task_id, 'role' AS reason, r.name AS role_name
|
||
FROM task_roles tr
|
||
JOIN role_members rm ON rm.role_id = tr.role_id AND rm.user_id = $2
|
||
JOIN roles r ON r.id = tr.role_id
|
||
JOIN tasks t3 ON t3.id = tr.task_id AND t3.organization_id = $1 AND t3.is_completed = false
|
||
WHERE tr.organization_id = $1
|
||
),
|
||
aggregated AS (
|
||
SELECT
|
||
task_id,
|
||
ARRAY_AGG(DISTINCT reason) AS reasons,
|
||
ARRAY_AGG(DISTINCT role_name) FILTER (WHERE role_name IS NOT NULL) AS role_names
|
||
FROM inbox_sources
|
||
GROUP BY task_id
|
||
)
|
||
SELECT
|
||
t.id,
|
||
t.title,
|
||
t.form_id AS "formId",
|
||
f.name AS "formName",
|
||
fs.name AS "statusName",
|
||
fs.color AS "statusColor",
|
||
fs.is_final AS "statusIsFinal",
|
||
t.current_status_id AS "statusId",
|
||
COALESCE(
|
||
CASE WHEN u.id IS NOT NULL THEN CONCAT(u.first_name, ' ', u.last_name) ELSE NULL END,
|
||
(SELECT STRING_AGG(CONCAT(ua.first_name, ' ', ua.last_name), ', ' ORDER BY ta2.id)
|
||
FROM task_assignees ta2 JOIN users ua ON ua.id = ta2.user_id
|
||
WHERE ta2.task_id = t.id)
|
||
) AS "assigneeName",
|
||
t.assigned_to AS "assignedTo",
|
||
t.created_at AS "createdAt",
|
||
t.updated_at AS "updatedAt",
|
||
t.due_date AS "dueDate",
|
||
t.is_completed AS "isCompleted",
|
||
a.reasons,
|
||
a.role_names AS "roleNames",
|
||
(
|
||
SELECT COUNT(*) FROM user_notifications un2
|
||
WHERE un2.task_id = t.id AND un2.user_id = $2 AND un2.is_read = false
|
||
)::int AS "unreadNotifications"
|
||
FROM aggregated a
|
||
JOIN tasks t ON t.id = a.task_id AND t.organization_id = $1
|
||
JOIN forms f ON f.id = t.form_id
|
||
JOIN form_statuses fs ON fs.id = t.current_status_id
|
||
LEFT JOIN users u ON u.id = t.assigned_to
|
||
WHERE t.is_completed = false
|
||
AND ($3 = '' OR t.title ILIKE '%' || $3 || '%')
|
||
AND ($4 = 0 OR t.form_id = $4)
|
||
AND ($7 = '' OR fs.name ILIKE '%' || $7 || '%')
|
||
AND ($8 = '' OR COALESCE(CONCAT(u.first_name, ' ', u.last_name), '') ILIKE '%' || $8 || '%'
|
||
OR EXISTS (SELECT 1 FROM task_assignees ta_f JOIN users ua_f ON ua_f.id = ta_f.user_id
|
||
WHERE ta_f.task_id = t.id AND CONCAT(ua_f.first_name, ' ', ua_f.last_name) ILIKE '%' || $8 || '%'))
|
||
AND (NOT $9 OR (t.due_date IS NOT NULL AND t.due_date < NOW()))
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM task_reminders tr_snooze
|
||
WHERE tr_snooze.task_id = t.id
|
||
AND tr_snooze.created_by_user_id = $2
|
||
AND tr_snooze.remind_at > NOW()
|
||
AND tr_snooze.is_sent = false
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM task_inbox_hidden tih
|
||
WHERE tih.task_id = t.id AND tih.user_id = $2
|
||
)
|
||
ORDER BY t.updated_at DESC
|
||
LIMIT $5 OFFSET $6
|
||
`, [organizationId, userId, '', 0, 500, 0, '', '', false]);
|
||
|
||
// Фильтруем выдачу по scopes.formIds (null = все формы)
|
||
const rows = (result.rows as Array<{ formId: number }>).filter((r) => isFormAllowed(r.formId));
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
tasks: rows.slice(0, limitVal),
|
||
total: rows.length,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Агрегации задач ────────────────────────────────────────────────────────
|
||
|
||
// aggregate_tasks
|
||
register(
|
||
"aggregate_tasks",
|
||
{
|
||
title: "Aggregate Tasks",
|
||
description:
|
||
"Compute aggregations over a numeric task field of a form (mirrors POST /api/forms/:id/tasks/aggregate). " +
|
||
"Each aggregation: { fieldId, fn } where fn is count|sum|avg|min|max. " +
|
||
"count = number of non-empty numeric values. Optionally filter tasks by statusId.",
|
||
inputSchema: {
|
||
formId: z.number().int().describe("The numeric ID of the form"),
|
||
aggregations: z.array(z.object({
|
||
fieldId: z.number().int().describe("Field ID whose numeric values are aggregated"),
|
||
fn: z.enum(["count", "sum", "avg", "min", "max"]).describe("Aggregation function"),
|
||
})).min(1).describe("Aggregations to compute"),
|
||
statusId: z.number().int().optional().describe("Only tasks in this status (optional)"),
|
||
},
|
||
},
|
||
async ({ formId, aggregations, statusId }) => {
|
||
if (!isFormAllowed(formId)) return formDenied(formId);
|
||
const form = await storage.getForm(formId, organizationId);
|
||
if (!form) return mcpError(`Форма ${formId} не найдена`);
|
||
|
||
// Фильтр доступа как в endpoint — от имени первого админа (админ видит все задачи)
|
||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||
const [result, accessibleIds] = await Promise.all([
|
||
storage.getTasksWithFieldsByFormOptimized(formId, organizationId, {}),
|
||
adminUser
|
||
? storage.getAccessibleTaskIds(adminUser.id, organizationId, adminUser.appRole)
|
||
: Promise.resolve(null),
|
||
]);
|
||
type TaskWithFields = (typeof result.tasks)[number] & { fieldValues?: Record<number, string | number | null> };
|
||
let tasks = result.tasks as TaskWithFields[];
|
||
if (accessibleIds !== null) {
|
||
tasks = tasks.filter((t) => accessibleIds.has(t.id));
|
||
}
|
||
if (statusId) {
|
||
tasks = tasks.filter((t) => t.currentStatusId === statusId);
|
||
}
|
||
|
||
const results = aggregations.map(({ fieldId, fn }) => {
|
||
const values: number[] = [];
|
||
for (const task of tasks) {
|
||
const fieldValue = task.fieldValues?.[fieldId];
|
||
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
|
||
const numValue = parseFloat(String(fieldValue));
|
||
if (!isNaN(numValue)) values.push(numValue);
|
||
}
|
||
}
|
||
let value: number | null = null;
|
||
switch (fn) {
|
||
case 'sum':
|
||
value = values.reduce((a, b) => a + b, 0);
|
||
break;
|
||
case 'avg':
|
||
value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||
break;
|
||
case 'count':
|
||
value = values.length;
|
||
break;
|
||
case 'min':
|
||
value = values.length > 0 ? Math.min(...values) : null;
|
||
break;
|
||
case 'max':
|
||
value = values.length > 0 ? Math.max(...values) : null;
|
||
break;
|
||
}
|
||
return { fieldId, fn, value, valuesCount: values.length };
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, formId, taskCount: tasks.length, results }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Обновление формы ───────────────────────────────────────────────────────
|
||
|
||
// update_form
|
||
register(
|
||
"update_form",
|
||
{
|
||
title: "Update Form",
|
||
description:
|
||
"Update safe form properties: name, description, visibility, qrGenerationEnabled, folderId. " +
|
||
"Does NOT touch fields, statuses or title template.",
|
||
inputSchema: {
|
||
formId: z.number().int().describe("The numeric ID of the form"),
|
||
name: z.string().min(1).optional().describe("New form name"),
|
||
description: z.string().nullable().optional().describe("New description (null to clear)"),
|
||
visibility: z.enum(["organization", "restricted"]).optional().describe("'organization' = visible to all org members, 'restricted' = access rules only"),
|
||
qrGenerationEnabled: z.boolean().optional().describe("Show QR generation button in the form registry"),
|
||
folderId: z.number().int().nullable().optional().describe("Move form to folder (null = root)"),
|
||
},
|
||
},
|
||
async ({ formId, name, description, visibility, qrGenerationEnabled, folderId }) => {
|
||
if (!isFormAllowed(formId)) return formDenied(formId);
|
||
const existingForm = await storage.getForm(formId, organizationId);
|
||
if (!existingForm) return mcpError(`Форма ${formId} не найдена`);
|
||
|
||
const updates: Record<string, unknown> = {};
|
||
if (name !== undefined) updates.name = name;
|
||
if (description !== undefined) updates.description = description;
|
||
if (visibility !== undefined) updates.visibility = visibility;
|
||
if (qrGenerationEnabled !== undefined) updates.qrGenerationEnabled = qrGenerationEnabled;
|
||
if (folderId !== undefined) updates.folderId = folderId;
|
||
if (Object.keys(updates).length === 0) {
|
||
return mcpError("Не переданы данные для обновления");
|
||
}
|
||
|
||
const updatedForm = await storage.updateForm(formId, organizationId, updates);
|
||
// Кэш и RAG-переиндексация — как в PUT /api/forms/:id
|
||
formsCache.invalidatePrefix(`forms:${organizationId}`);
|
||
indexFormAsync(formId, organizationId).catch(() => {});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
form: {
|
||
id: updatedForm.id,
|
||
name: updatedForm.name,
|
||
description: updatedForm.description,
|
||
visibility: updatedForm.visibility,
|
||
qrGenerationEnabled: updatedForm.qrGenerationEnabled,
|
||
folderId: updatedForm.folderId,
|
||
},
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Дерево задач ───────────────────────────────────────────────────────────
|
||
|
||
// get_task_tree
|
||
register(
|
||
"get_task_tree",
|
||
{
|
||
title: "Get Task Tree",
|
||
description:
|
||
"Get the hierarchy of a task: parent chain (via parentTaskId, up to 10 levels) and the recursive subtasks tree. " +
|
||
"Nodes from forms not allowed by the API key are filtered out.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
},
|
||
},
|
||
async ({ taskId }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const tree = await storage.getTaskTree(taskId, organizationId);
|
||
|
||
// Компактный маппинг узлов с отсечением поддеревьев недоступных форм
|
||
const mapNode = (node: Record<string, unknown> | null): unknown => {
|
||
if (!node || typeof node.formId !== 'number' || !isFormAllowed(node.formId)) return null;
|
||
const subtasks = Array.isArray(node.subtasks) ? node.subtasks : [];
|
||
return {
|
||
id: node.id,
|
||
title: node.title,
|
||
formId: node.formId,
|
||
currentStatusId: node.currentStatusId,
|
||
isCompleted: node.isCompleted,
|
||
dueDate: node.dueDate,
|
||
subtasks: subtasks.map((s) => mapNode(s as Record<string, unknown>)).filter(Boolean),
|
||
};
|
||
};
|
||
|
||
// Цепочка родителей вверх по parentTaskId (ближайший первым, до 10 уровней,
|
||
// один recursive CTE вместо последовательных getTask на каждый уровень)
|
||
const parentChain = await storage.getTaskParentChain(taskId, organizationId, 10);
|
||
const parents: Array<Record<string, unknown>> = [];
|
||
for (const parent of parentChain) {
|
||
if (isFormAllowed(parent.formId)) {
|
||
parents.push({
|
||
id: parent.id,
|
||
title: parent.title,
|
||
formId: parent.formId,
|
||
currentStatusId: parent.currentStatusId,
|
||
isCompleted: parent.isCompleted,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, task: mapNode(tree), parents }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── Кастомные поля пользователей ───────────────────────────────────────────
|
||
|
||
// get_user_field_values
|
||
register(
|
||
"get_user_field_values",
|
||
{
|
||
title: "Get User Field Values",
|
||
description: "Get custom field values of a user, enriched with field codes and names",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("The numeric ID of the user"),
|
||
},
|
||
},
|
||
async ({ userId }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
const [fields, values] = await Promise.all([
|
||
storage.getUserCustomFields(organizationId),
|
||
storage.getUserCustomValues(userId, organizationId),
|
||
]);
|
||
const fieldMap = new Map(fields.map((f) => [f.id, f]));
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
userId,
|
||
values: values.map((v) => ({
|
||
fieldId: v.fieldId,
|
||
fieldCode: fieldMap.get(v.fieldId)?.code ?? null,
|
||
fieldName: fieldMap.get(v.fieldId)?.name ?? null,
|
||
value: v.value,
|
||
})),
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// set_user_field_value
|
||
register(
|
||
"set_user_field_value",
|
||
{
|
||
title: "Set User Field Value",
|
||
description: "Set a custom field value for a user by field code. Use list_users or get_user_field_values to discover field codes.",
|
||
inputSchema: {
|
||
userId: z.number().int().describe("The numeric ID of the user"),
|
||
fieldCode: z.string().min(1).describe("Custom field code"),
|
||
value: z.string().nullable().describe("New value (null to clear)"),
|
||
},
|
||
},
|
||
async ({ userId, fieldCode, value }) => {
|
||
const user = await storage.getUser(userId);
|
||
if (!user || user.organizationId !== organizationId) {
|
||
return mcpError(`Пользователь ${userId} не найден в организации`);
|
||
}
|
||
const fields = await storage.getUserCustomFields(organizationId);
|
||
const field = fields.find((f) => f.code === fieldCode);
|
||
if (!field) {
|
||
return mcpError(`Поле с кодом "${fieldCode}" не найдено. Доступные коды: ${fields.map((f) => f.code).join(', ') || '(нет)'}`);
|
||
}
|
||
await storage.setUserCustomValues(userId, [{ fieldId: field.id, value }], organizationId);
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, userId, fieldCode, value }, null, 2),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
// ── DaData подсказки ───────────────────────────────────────────────────────
|
||
|
||
// dadata_suggest
|
||
register(
|
||
"dadata_suggest",
|
||
{
|
||
title: "DaData Suggest",
|
||
description:
|
||
"Get DaData suggestions for organizations ('party') or addresses ('address'). " +
|
||
"Uses the organization DaData API key, falling back to the global DADATA_API_KEY env variable.",
|
||
inputSchema: {
|
||
type: z.enum(["party", "address"]).describe("'party' = organizations, 'address' = addresses"),
|
||
query: z.string().min(1).describe("Search query"),
|
||
count: z.number().int().min(1).max(20).optional().describe("Max suggestions (default 10, max 20)"),
|
||
},
|
||
},
|
||
async ({ type, query, count }) => {
|
||
// Ключ: сначала org-specific сервис, затем глобальный env (как в external.routes.ts)
|
||
const orgService = await storage.getExternalServiceByType('dadata', organizationId);
|
||
let apiKey: string | null = null;
|
||
if (orgService && orgService.apiKey) {
|
||
apiKey = decryptSecret(orgService.apiKey);
|
||
} else {
|
||
apiKey = process.env.DADATA_API_KEY || null;
|
||
}
|
||
if (!apiKey) {
|
||
return mcpError('API-ключ DaData не настроен. Добавьте его в разделе "Доступы к сервисам".');
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/${type}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'Authorization': `Token ${apiKey}`,
|
||
},
|
||
body: JSON.stringify({ query, count: Math.min(count ?? 10, 20) }),
|
||
});
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error('Dadata API error (MCP):', errorText);
|
||
return mcpError("Ошибка API DaData");
|
||
}
|
||
const data = await response.json();
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({ success: true, suggestions: data.suggestions }, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка при запросе к DaData: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// ── Привязка файлов (fileUrl после REST-загрузки) ──────────────────────────
|
||
|
||
// upload_task_file
|
||
register(
|
||
"upload_task_file",
|
||
{
|
||
title: "Upload Task Field File",
|
||
description:
|
||
"Attach a file previously uploaded via REST POST /api/upload (with the same API key) to a file-type form field of a task — APPENDs to the field value. " +
|
||
"Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " +
|
||
"File fields are multiple: the value is an array of {url, name, size}. " +
|
||
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
fieldId: z.number().int().describe("The numeric ID of the file-type form field"),
|
||
fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
|
||
fileName: z.string().min(1).describe("Original file name (as shown to users)"),
|
||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0; pass the size from the /api/upload response)"),
|
||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||
},
|
||
},
|
||
async ({ taskId, fieldId, fileUrl, fileName, fileSize, mimeType }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const formFields = await storage.getFormFields(task.formId, organizationId);
|
||
const field = formFields.find((f) => f.id === fieldId);
|
||
if (!field) return mcpError(`Поле ${fieldId} не найдено в форме ${task.formId}`);
|
||
if (field.type !== 'file') {
|
||
return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`);
|
||
}
|
||
|
||
try {
|
||
const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType });
|
||
if ('error' in source) return mcpError(source.error);
|
||
const { file } = source;
|
||
const actor = await getActor();
|
||
|
||
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
|
||
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||
const existingValue = existingValues.find((v) => v.fieldId === fieldId);
|
||
const currentFiles = Array.isArray(existingValue?.value) ? existingValue.value as Array<Record<string, unknown>> : [];
|
||
const newFiles = [...currentFiles, { url: file.url, name: file.name, size: file.size }];
|
||
|
||
// Лимиты поля (как в PATCH /api/tasks/:id/field-values/:fieldId)
|
||
if (field.maxFileCount != null && newFiles.length > field.maxFileCount) {
|
||
return mcpError(`Превышено максимальное количество файлов (${field.maxFileCount})`);
|
||
}
|
||
if (field.maxFileSizeMB != null) {
|
||
const totalBytes = newFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||
if (totalBytes > field.maxFileSizeMB * 1024 * 1024) {
|
||
return mcpError(`Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`);
|
||
}
|
||
}
|
||
|
||
if (existingValue) {
|
||
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: newFiles });
|
||
} else {
|
||
await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles });
|
||
}
|
||
|
||
storage.addTaskAuditLog({
|
||
taskId,
|
||
organizationId,
|
||
action: 'field.changed',
|
||
fieldId: field.id,
|
||
fieldName: field.name,
|
||
oldValue: existingValue?.value ?? null,
|
||
newValue: newFiles,
|
||
...actorAudit(actor),
|
||
metadata: { source: 'mcp' },
|
||
}).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); });
|
||
|
||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||
indexTaskAsync(taskId, organizationId).catch(() => {});
|
||
const freshTask = await storage.getTask(taskId, organizationId);
|
||
eventBus.publishEvent({
|
||
type: 'task_updated',
|
||
organizationId,
|
||
data: { taskId, formId: task.formId, task: freshTask, autoTransition: autoResult.changed },
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||
fieldValue: newFiles,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка привязки файла: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// upload_message_file
|
||
register(
|
||
"upload_message_file",
|
||
{
|
||
title: "Upload Message Attachment",
|
||
description:
|
||
"Attach a file previously uploaded via REST POST /api/upload (with the same API key) as a task comment attachment. " +
|
||
"Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " +
|
||
"If content is omitted, the message text is generated as '📎 <file name>'. " +
|
||
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
|
||
fileName: z.string().min(1).describe("Original file name (as shown to users)"),
|
||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"),
|
||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
|
||
},
|
||
},
|
||
async ({ taskId, fileUrl, fileName, fileSize, mimeType, content }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
try {
|
||
const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType });
|
||
if ('error' in source) return mcpError(source.error);
|
||
const { file } = source;
|
||
const actor = await getActor();
|
||
|
||
const created = await sendTaskMessage({
|
||
task,
|
||
user: actor.bot ? undefined : actor.user,
|
||
botId: actor.bot?.id ?? null,
|
||
organizationId,
|
||
message: content?.trim() || `📎 ${file.name}`,
|
||
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
|
||
});
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||
message: { id: created.id, taskId: created.taskId, message: created.message, createdAt: created.createdAt },
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
if (err instanceof SendTaskMessageError) return mcpError(err.message);
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка привязки файла: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// upload_directory_file
|
||
register(
|
||
"upload_directory_file",
|
||
{
|
||
title: "Upload Directory Cell File",
|
||
description:
|
||
"Attach a file previously uploaded via REST POST /api/upload (with the same API key) into a directory row cell. " +
|
||
"Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " +
|
||
"Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
|
||
inputSchema: {
|
||
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
|
||
rowId: z.number().int().describe("The numeric ID of the row"),
|
||
columnIndex: z.number().int().min(0).describe("Column index (0-based)"),
|
||
fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
|
||
fileName: z.string().min(1).describe("Original file name (as shown to users)"),
|
||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"),
|
||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||
},
|
||
},
|
||
async ({ tableId, rowId, columnIndex, fileUrl, fileName, fileSize, mimeType }) => {
|
||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||
const table = await storage.getDataTable(tableId, organizationId);
|
||
if (!table) return mcpError(`Справочник ${tableId} не найден`);
|
||
const columnCount = table.columns?.length ?? 0;
|
||
if (columnIndex < 0 || columnIndex >= columnCount) {
|
||
return mcpError(`Колонка ${columnIndex} вне диапазона (в справочнике ${columnCount} колонок)`);
|
||
}
|
||
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
|
||
if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
|
||
|
||
try {
|
||
const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType });
|
||
if ('error' in source) return mcpError(source.error);
|
||
const { file } = source;
|
||
|
||
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
|
||
const values = Array.isArray(row.values) ? [...row.values] : [];
|
||
while (values.length < columnCount) values.push('');
|
||
values[columnIndex] = `[${file.name}](${file.url})`;
|
||
const updated = await storage.updateDataTableRow(rowId, tableId, organizationId, { values });
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||
row: { id: updated.id, values: updated.values },
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка привязки файла: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// upload_table_row_file
|
||
register(
|
||
"upload_table_row_file",
|
||
{
|
||
title: "Upload Table Tab Cell File",
|
||
description:
|
||
"Attach a file previously uploaded via REST POST /api/upload (with the same API key) into a cell of a task's 'table' tab (regular_table_rows). " +
|
||
"Do NOT try to pass file content through MCP — upload the binary via REST first (see get_api_guide). " +
|
||
"The cell gets a markdown link: [file name](url). " +
|
||
"If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
|
||
inputSchema: {
|
||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||
tabId: z.number().int().describe("The numeric ID of the table tab"),
|
||
columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"),
|
||
rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."),
|
||
fileUrl: z.string().min(1).describe("URL of the uploaded file from POST /api/upload (/api/files/... or /uploads/...)"),
|
||
fileName: z.string().min(1).describe("Original file name (as shown to users)"),
|
||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (default 0)"),
|
||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||
},
|
||
},
|
||
async ({ taskId, tabId, columnId, rowId, fileUrl, fileName, fileSize, mimeType }) => {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
|
||
const tab = await storage.getFormTab(tabId, task.formId, organizationId);
|
||
if (!tab) return mcpError(`Таб ${tabId} не найден в форме ${task.formId}`);
|
||
if (tab.type !== 'table') return mcpError(`Таб ${tabId} имеет тип '${tab.type}', а не 'table'`);
|
||
type ColDef = { id: string; name: string; type?: string };
|
||
const columns: ColDef[] = Array.isArray(tab.tableColumns) ? (tab.tableColumns as ColDef[]) : [];
|
||
if (!columns.some((c) => c.id === columnId)) {
|
||
return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`);
|
||
}
|
||
|
||
try {
|
||
const source = resolveUploadSource({ fileName, fileUrl, fileSize, mimeType });
|
||
if ('error' in source) return mcpError(source.error);
|
||
const { file } = source;
|
||
const actor = await getActor();
|
||
|
||
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
|
||
// пишем markdown-ссылку [имя](url), как и в справочниках
|
||
const cellValue = `[${file.name}](${file.url})`;
|
||
|
||
let row;
|
||
if (rowId !== undefined) {
|
||
const existing = await storage.getRegularTableRow(rowId, taskId, tabId);
|
||
if (!existing) return mcpError(`Строка ${rowId} не найдена в табе ${tabId}`);
|
||
const data = { ...((existing.data ?? {}) as Record<string, unknown>), [columnId]: cellValue };
|
||
row = await storage.updateRegularTableRow(rowId, taskId, tabId, { data });
|
||
} else {
|
||
row = await storage.createRegularTableRow({
|
||
taskId,
|
||
tabId,
|
||
data: { [columnId]: cellValue },
|
||
createdBy: actor.user.id,
|
||
});
|
||
}
|
||
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
|
||
row: { id: row.id, data: row.data },
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка привязки файла: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// create_upload_ticket — короткоживущий URL загрузки БЕЗ API-ключа.
|
||
// Сценарий: агент видит MCP-инструменты, но значение ключа зашито в конфиге
|
||
// его MCP-клиента — выполнить curl с X-Api-Key он не может. Тикет решает это.
|
||
register(
|
||
"create_upload_ticket",
|
||
{
|
||
title: "Create Upload Ticket",
|
||
description:
|
||
"Get a short-lived (10 min) upload URL so files can be uploaded via plain curl WITHOUT the API key " +
|
||
"(the key value is hidden in your MCP client config and cannot be used in shell commands). " +
|
||
"Upload the binary with `curl -F \"file=@...\" \"<uploadUrl>?taskId=...&fieldId=...\"` (no auth headers), " +
|
||
"then attach the returned url via upload_task_file / upload_message_file / upload_directory_file / upload_table_row_file (fileUrl). " +
|
||
"The ticket is reusable within its TTL and bound to your organization and key scopes.",
|
||
inputSchema: {
|
||
taskId: z.number().int().optional().describe("Optional task ID the file will be attached to — checked against key scopes and embedded into curlExample"),
|
||
fieldId: z.number().int().optional().describe("Optional file field ID — embedded into curlExample"),
|
||
},
|
||
},
|
||
async ({ taskId, fieldId }) => {
|
||
if (scopes.mode === 'read') {
|
||
return mcpError('Загрузка файлов недоступна в режиме только чтение (scopes.mode = read)');
|
||
}
|
||
if (taskId !== undefined) {
|
||
const task = await storage.getTask(taskId, organizationId);
|
||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||
}
|
||
try {
|
||
// Автор загрузки по тикету = владелец ключа (fallback: актор организации)
|
||
let createdBy = apiKeyRecord?.createdBy;
|
||
if (!createdBy) createdBy = (await getActor()).user.id;
|
||
const ticket = createUploadTicket({
|
||
organizationId,
|
||
apiKeyId: apiKeyRecord?.id ?? null,
|
||
botId: apiKeyRecord?.botId ?? null,
|
||
createdBy,
|
||
scopes,
|
||
});
|
||
const baseUrl = (process.env.PUBLIC_APP_URL || process.env.APP_URL || process.env.BASE_URL || 'https://iistwin.ru').replace(/\/+$/, '');
|
||
const uploadUrl = `${baseUrl}/api/upload/ticket/${ticket.token}`;
|
||
const query = [
|
||
taskId !== undefined ? `taskId=${taskId}` : null,
|
||
fieldId !== undefined ? `fieldId=${fieldId}` : null,
|
||
].filter(Boolean).join('&');
|
||
const curlExample = `curl -F "file=@<путь_к_файлу>" "${uploadUrl}${query ? `?${query}` : ''}"`;
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
success: true,
|
||
uploadUrl,
|
||
expiresAt: ticket.expiresAt.toISOString(),
|
||
curlExample,
|
||
}, null, 2),
|
||
}],
|
||
};
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return mcpError(`Ошибка создания тикета загрузки: ${msg}`);
|
||
}
|
||
}
|
||
);
|
||
|
||
// get_api_guide
|
||
register(
|
||
"get_api_guide",
|
||
{
|
||
title: "Get API Guide",
|
||
description: "Returns a compact Russian-language guide for AI agents: how to upload files via create_upload_ticket (curl WITHOUT the API key) and create tasks/comments via REST, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
|
||
inputSchema: {},
|
||
},
|
||
async () => {
|
||
const guide = `
|
||
# Работа с API iistwin по ключу (гайд для ИИ-агента)
|
||
|
||
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
|
||
|
||
## 1. Загрузка файла через upload-тикет (БЕЗ API-ключа — основной способ)
|
||
|
||
Значение ключа зашито в конфиге твоего MCP-клиента и тебе недоступно, поэтому
|
||
для curl используй короткоживущий тикет загрузки:
|
||
|
||
**Шаг 1.** Вызови MCP-инструмент \`create_upload_ticket({ taskId?, fieldId? })\` →
|
||
получишь \`uploadUrl\`, \`expiresAt\` (TTL 10 минут) и готовый \`curlExample\`.
|
||
|
||
**Шаг 2.** Загрузи файл БЕЗ заголовков авторизации:
|
||
|
||
\`\`\`bash
|
||
curl -F "file=@/path/report.pdf" "<uploadUrl>?taskId=<taskId>&fieldId=<fieldId>"
|
||
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
|
||
\`\`\`
|
||
|
||
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
|
||
- Тикет многоразовый в пределах TTL (можно загрузить несколько файлов), привязан к твоей организации.
|
||
- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ,
|
||
документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ.
|
||
- Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar.
|
||
Для jpg/png/pdf проверяются magic bytes.
|
||
- Из ответа возьми \`url\`, \`name\`, \`size\` — они нужны для привязки (п.2).
|
||
|
||
Если значение ключа тебе ИЗВЕСТНО, можно загружать напрямую:
|
||
\`curl -F "file=@..." -H "X-Api-Key: $KEY" "https://iistwin.ru/api/upload?taskId=...&fieldId=..."\`.
|
||
|
||
## 2. Привязка файла к задаче/справочнику (MCP, по fileUrl)
|
||
|
||
После загрузки вызови нужный MCP-инструмент с \`fileUrl\` (и \`fileName\`, желательно \`fileSize\` из ответа upload):
|
||
- \`upload_task_file({ taskId, fieldId, fileUrl, fileName, fileSize? })\` — добавит файл в file-поле задачи.
|
||
- \`upload_message_file({ taskId, fileUrl, fileName, content? })\` — комментарий с вложением.
|
||
- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl, fileName })\` — ссылка в ячейку справочника.
|
||
- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl, fileName })\` — ссылка в ячейку таб-таблицы.
|
||
|
||
Либо напрямую REST (file-поле — массив объектов {url, name, size}):
|
||
\`\`\`bash
|
||
# Прочитать текущее значение, ДОБАВИТЬ объект, записать назад:
|
||
curl -X PATCH -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||
-d '{"value":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||
"https://iistwin.ru/api/tasks/<taskId>/field-values/<fieldId>"
|
||
\`\`\`
|
||
ВНИМАНИЕ: PATCH полностью заменяет значение поля — сначала прочитай задачу
|
||
(\`get_task\` в MCP или GET /api/tasks/<id> в REST) и добавь файл к существующему массиву.
|
||
|
||
## 3. Создание задачи и комментария через REST
|
||
|
||
\`\`\`bash
|
||
# Задача (customFields: { "customField_<fieldId>": значение })
|
||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||
-d '{"title":"Новая задача","customFields":{"customField_12":"Текст"}}' \\
|
||
"https://iistwin.ru/api/forms/<formId>/tasks"
|
||
|
||
# Комментарий (с вложением — attachments: [{url, name, size, mimeType?}])
|
||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||
-d '{"message":"Готово","attachments":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||
"https://iistwin.ru/api/tasks/<taskId>/messages"
|
||
\`\`\`
|
||
|
||
## 4. Права ключа
|
||
|
||
- mode=read: только чтение (запись → 403). mode=write: чтение+создание. mode=full: всё.
|
||
- formIds/tableIds ограничивают список доступных форм/справочников (null = все).
|
||
- REST по ключу открыт только для: POST /api/upload, POST /api/tasks/:id/messages,
|
||
PATCH /api/tasks/:id/field-values/:fieldId, POST /api/tasks/:id/field-values,
|
||
POST /api/forms/:id/tasks. Остальное — через MCP-инструменты.
|
||
- Атрибуция: действия по ключу бота записываются в историю от имени бота (bot_id),
|
||
по обычному ключу — «Ключ "label"» (metadata.source = 'mcp' | 'api').
|
||
|
||
## 5. Форматы значений
|
||
|
||
- file-поле задачи: массив \`[{url, name, size}]\` (множественное — добавляй, не заменяй).
|
||
- Вложение сообщения: \`{url, name, size, mimeType?}\`.
|
||
- Ячейка справочника/таб-таблицы: markdown-ссылка \`[имя](url)\`.
|
||
- url файла: \`/api/files/<key>\` (S3) или \`/uploads/<key>\` (локальный режим).
|
||
`.trim();
|
||
return { content: [{ type: "text" as const, text: guide }] };
|
||
}
|
||
);
|
||
|
||
return server;
|
||
}
|
||
|
||
// In-memory SSE sessions: sessionId -> { transport, organizationId, scopes } (legacy)
|
||
const sseSessions: Map<string, { transport: SSEServerTransport; organizationId: number; scopes: ApiKeyScopes }> = new Map();
|
||
|
||
// Stateful Streamable HTTP transports: sessionId -> { transport, server, organizationId, scopes }
|
||
const mcpTransports = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer; organizationId: number; scopes: ApiKeyScopes }>();
|
||
|
||
// Сравнение скоупов: при изменении прав ключа старая сессия недействительна,
|
||
// т.к. набор инструментов фиксируется при создании MCP-сервера.
|
||
function scopesEqual(a: ApiKeyScopes, b: ApiKeyScopes): boolean {
|
||
return JSON.stringify(a) === JSON.stringify(b);
|
||
}
|
||
|
||
function createJsonRpcErrorResponse(code: number, message: string) {
|
||
return { jsonrpc: "2.0" as const, error: { code, message }, id: null };
|
||
}
|
||
|
||
// GET/POST/DELETE /mcp — Streamable HTTP (stateful, modern clients: Cursor, Cline, Kimi CLI, etc.)
|
||
export async function handleMcpRequest(req: Request, res: Response) {
|
||
try {
|
||
const resolved = await resolveApiKey(req);
|
||
if (!resolved) {
|
||
if (!res.headersSent) {
|
||
res.status(401).json(createJsonRpcErrorResponse(-32001, "Invalid or missing API key. Provide X-Api-Key header."));
|
||
}
|
||
return;
|
||
}
|
||
const { organizationId, scopes, key } = resolved;
|
||
|
||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||
|
||
// POST with initialize request and no session ID -> create new transport
|
||
if (req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
|
||
const transport = new StreamableHTTPServerTransport({
|
||
sessionIdGenerator: () => crypto.randomUUID(),
|
||
onsessioninitialized: (sid) => {
|
||
mcpTransports.set(sid, { transport, server, organizationId, scopes });
|
||
},
|
||
});
|
||
const server = buildMcpServer(organizationId, scopes, key);
|
||
|
||
await server.connect(transport);
|
||
|
||
transport.onclose = () => {
|
||
if (transport.sessionId) {
|
||
mcpTransports.delete(transport.sessionId);
|
||
}
|
||
};
|
||
|
||
await transport.handleRequest(req, res, req.body);
|
||
return;
|
||
}
|
||
|
||
// Existing session
|
||
if (sessionId && mcpTransports.has(sessionId)) {
|
||
const session = mcpTransports.get(sessionId)!;
|
||
// Re-validate organization from API key to prevent session hijacking
|
||
if (session.organizationId !== organizationId) {
|
||
if (!res.headersSent) {
|
||
res.status(401).json(createJsonRpcErrorResponse(-32001, "Invalid API key for this session."));
|
||
}
|
||
return;
|
||
}
|
||
// Перечитываем скоупы при ревалидации ключа: если права изменились,
|
||
// закрываем сессию — клиент должен переподключиться с новым набором инструментов.
|
||
if (!scopesEqual(session.scopes, scopes)) {
|
||
mcpTransports.delete(sessionId);
|
||
session.transport.close().catch(() => {});
|
||
if (!res.headersSent) {
|
||
res.status(401).json(createJsonRpcErrorResponse(-32001, "Права API-ключа изменены. Переподключитесь (новая MCP-сессия)."));
|
||
}
|
||
return;
|
||
}
|
||
session.scopes = scopes;
|
||
await session.transport.handleRequest(req, res, req.body);
|
||
return;
|
||
}
|
||
|
||
// Invalid request
|
||
if (!res.headersSent) {
|
||
res.status(400).json(createJsonRpcErrorResponse(-32000, "Bad Request: No valid session ID provided"));
|
||
}
|
||
} catch (error) {
|
||
console.error("[MCP] Error handling request:", error);
|
||
if (!res.headersSent) {
|
||
res.status(500).json(createJsonRpcErrorResponse(-32603, "Internal server error"));
|
||
}
|
||
}
|
||
}
|
||
|
||
// GET /mcp/sse — Legacy SSE transport (Claude Desktop)
|
||
export async function handleMcpSse(req: Request, res: Response) {
|
||
const resolved = await resolveApiKey(req);
|
||
if (!resolved) {
|
||
res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." });
|
||
return;
|
||
}
|
||
const { organizationId, scopes, key } = resolved;
|
||
|
||
const transport = new SSEServerTransport("/mcp/messages", res);
|
||
const sessionId = transport.sessionId;
|
||
|
||
sseSessions.set(sessionId, { transport, organizationId, scopes });
|
||
|
||
transport.onclose = () => {
|
||
sseSessions.delete(sessionId);
|
||
};
|
||
|
||
const server = buildMcpServer(organizationId, scopes, key);
|
||
await server.connect(transport);
|
||
}
|
||
|
||
// POST /mcp/messages — Legacy SSE message handler
|
||
export async function handleMcpMessages(req: Request, res: Response) {
|
||
const sessionId = req.query.sessionId as string;
|
||
if (!sessionId) {
|
||
res.status(400).json({ error: "Missing sessionId query parameter" });
|
||
return;
|
||
}
|
||
|
||
const session = sseSessions.get(sessionId);
|
||
if (!session) {
|
||
res.status(404).json({ error: "Session not found or expired" });
|
||
return;
|
||
}
|
||
|
||
// Re-validate the API key on each message to prevent session hijacking
|
||
const resolved = await resolveApiKey(req);
|
||
if (!resolved || resolved.organizationId !== session.organizationId) {
|
||
res.status(401).json({ error: "Invalid or missing API key" });
|
||
return;
|
||
}
|
||
// Перечитываем скоупы при ревалидации: при изменении прав закрываем сессию
|
||
if (!scopesEqual(session.scopes, resolved.scopes)) {
|
||
sseSessions.delete(sessionId);
|
||
session.transport.close().catch(() => {});
|
||
res.status(401).json({ error: "Права API-ключа изменены. Переподключитесь (новая MCP-сессия)." });
|
||
return;
|
||
}
|
||
session.scopes = resolved.scopes;
|
||
|
||
await session.transport.handlePostMessage(req, res, req.body);
|
||
}
|