- 15 инструментов: list/get_directory, list_directory_rows (flat/tree), get_directory_row, get_directory_column_values (read); create/bulk_create_directory_row (write); create/update/delete_directory, update/delete/move_directory_row, link/unlink_directories (full) - Объектные проверки isTableAllowed по scopes.tableIds - Логика дерева строк вынесена в server/utils/data-table-tree.ts (переиспользуется REST-эндпоинтом и MCP) - bulkCreateDataTableRows: поддержка parentId и position
3685 lines
153 KiB
TypeScript
3685 lines
153 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";
|
||
|
||
|
||
// 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 } from "@shared/schema";
|
||
import { normalizeApiKeyScopes } from "./utils/api-key";
|
||
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";
|
||
|
||
/** 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',
|
||
'semantic_search',
|
||
'get_organization_context',
|
||
'list_directories',
|
||
'get_directory',
|
||
'list_directory_rows',
|
||
'get_directory_row',
|
||
'get_directory_column_values',
|
||
];
|
||
|
||
// 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',
|
||
];
|
||
|
||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||
// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full.
|
||
|
||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа.
|
||
export interface ResolvedApiKey {
|
||
organizationId: number;
|
||
scopes: ApiKeyScopes;
|
||
}
|
||
|
||
// Разрешает 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) };
|
||
}
|
||
|
||
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): 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) => scopes.formIds === null || scopes.formIds.includes(formId);
|
||
const formDenied = (formId: number) => ({
|
||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }],
|
||
isError: true,
|
||
});
|
||
|
||
// ── Объектный доступ по 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,
|
||
});
|
||
|
||
// Нормализация 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 orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||
if (!adminUser) {
|
||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||
}
|
||
|
||
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: adminUser.id,
|
||
assignedTo: assigned_to ?? null,
|
||
currentStatusId: resolvedStatusId,
|
||
description: description ?? null,
|
||
dueDate: parsedDueDate,
|
||
completedAt: isFinalStatus ? new Date() : null,
|
||
isCompleted: isFinalStatus,
|
||
parentTaskId: null,
|
||
});
|
||
|
||
const creatorName = `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP";
|
||
storage.addTaskAuditLog({
|
||
taskId: task.id,
|
||
organizationId,
|
||
action: "task.created",
|
||
changedBy: adminUser.id,
|
||
changedByName: creatorName,
|
||
metadata: { title: task.title },
|
||
}).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,
|
||
});
|
||
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);
|
||
return {
|
||
content: [
|
||
{
|
||
type: "text" as const,
|
||
text: JSON.stringify(
|
||
{ success: true, task: { id: updated.id, title: updated.title, isCompleted: updated.isCompleted } },
|
||
null,
|
||
2
|
||
),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
);
|
||
|
||
// 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: 500, 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 orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||
if (!adminUser) {
|
||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||
}
|
||
|
||
const form = await storage.createForm({
|
||
organizationId,
|
||
name,
|
||
description: description ?? null,
|
||
createdBy: adminUser.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 adminUsers = await storage.getUsersByOrganization(organizationId);
|
||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||
if (!createdBy) {
|
||
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
|
||
}
|
||
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 adminUsers = await storage.getUsersByOrganization(organizationId);
|
||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||
if (!createdBy) {
|
||
return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
|
||
}
|
||
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 }],
|
||
};
|
||
}
|
||
);
|
||
|
||
// create_custom_page
|
||
register(
|
||
"create_custom_page",
|
||
{
|
||
title: "Create Custom Page",
|
||
description: `Create a new custom JS page that appears 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.
|
||
- 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)"),
|
||
},
|
||
},
|
||
async ({ name, slug, code, description, icon, form_ids }) => {
|
||
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 adminUsers = await storage.getUsersByOrganization(organizationId);
|
||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||
if (!createdBy) {
|
||
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
|
||
}
|
||
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,
|
||
});
|
||
const createResult: Record<string, unknown> = { success: true, page };
|
||
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.
|
||
- 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"),
|
||
},
|
||
},
|
||
async ({ page_id, name, code, description, icon, form_ids, is_active }) => {
|
||
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;
|
||
|
||
const page = await storage.updateCustomPage(page_id, organizationId, updates as any);
|
||
const result: Record<string, unknown> = { success: true, page };
|
||
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)
|
||
},
|
||
|
||
// 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: { ... } }
|
||
- qr.scan: { template, objectId, scannedByUserId }
|
||
|
||
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
|
||
|
||
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
|
||
|
||
Trigger types: 'manual', 'task.before_create', 'task.created', 'task.status_changed', 'qr.scan'
|
||
TriggerConfig for task.before_create / task.created / task.status_changed: { formId: number, statusId?: number }
|
||
TriggerConfig for qr.scan: { templateKey: string }
|
||
|
||
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"]).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 } or { templateKey: 'equipment-scan' }"),
|
||
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 users = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = users.find(u => u.appRole === 'admin');
|
||
const createdBy = adminUser?.id;
|
||
if (!createdBy) return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
|
||
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"]).optional().describe("New trigger type"),
|
||
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 orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||
if (!adminUser) {
|
||
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
|
||
}
|
||
|
||
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: adminUser.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 adminUsers = await storage.getUsersByOrganization(organizationId);
|
||
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
|
||
if (!createdBy) {
|
||
return { content: [{ type: "text" as const, text: "No user found to assign createdBy" }], isError: true };
|
||
}
|
||
|
||
const row = await storage.createRegularTableRow({
|
||
taskId: task_id,
|
||
tabId: resolvedTabId,
|
||
data: data ?? {},
|
||
createdBy,
|
||
});
|
||
|
||
const tab = await storage.getFormTab(resolvedTabId, task.formId, organizationId);
|
||
const adminUser = adminUsers.find((u) => u.id === createdBy);
|
||
const editorName = adminUser
|
||
? `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP"
|
||
: "MCP";
|
||
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,
|
||
changedBy: createdBy,
|
||
changedByName: editorName,
|
||
}).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));
|
||
const result = await Promise.all(
|
||
allowed.map(async (t) => {
|
||
const rows = await storage.getDataTableRows(t.id, organizationId);
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
description: t.description,
|
||
columns: t.columns,
|
||
rowCount: rows.length,
|
||
};
|
||
})
|
||
);
|
||
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} не найден`);
|
||
|
||
let rows = await storage.getDataTableRows(tableId, organizationId);
|
||
|
||
if (mode === "tree") {
|
||
const tree = buildDataTableTree(table, rows, { filters, search });
|
||
return {
|
||
content: [{ type: "text" as const, text: JSON.stringify(tree, null, 2) }],
|
||
};
|
||
}
|
||
|
||
// flat: фильтры по подстроке (includes), без учёта регистра
|
||
if (filters) {
|
||
Object.entries(filters).forEach(([col, val]) => {
|
||
const ci = parseInt(col);
|
||
if (!isNaN(ci) && val) {
|
||
rows = rows.filter((r) => {
|
||
const v = Array.isArray(r.values) ? r.values : [];
|
||
return String(v[ci] || '').toLowerCase().includes(val.toLowerCase());
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Поиск по всем значениям строки
|
||
if (search) {
|
||
const q = search.toLowerCase();
|
||
rows = rows.filter((r) => {
|
||
const v = Array.isArray(r.values) ? r.values : [];
|
||
return v.some((val) => String(val ?? '').toLowerCase().includes(q));
|
||
});
|
||
}
|
||
|
||
// Сортировка по колонке
|
||
if (sortColumn !== undefined && (sortDirection === 'asc' || sortDirection === 'desc')) {
|
||
const ci = sortColumn;
|
||
rows = [...rows].sort((a, b) => {
|
||
const av = Array.isArray(a.values) ? a.values : [];
|
||
const bv = Array.isArray(b.values) ? b.values : [];
|
||
const cmp = String(av[ci] || '').toLowerCase().localeCompare(String(bv[ci] || '').toLowerCase(), 'ru');
|
||
return sortDirection === 'asc' ? cmp : -cmp;
|
||
});
|
||
}
|
||
|
||
const total = rows.length;
|
||
const paged = rows.slice(offset ?? 0, (offset ?? 0) + (limit ?? 100));
|
||
return {
|
||
content: [{
|
||
type: "text" as const,
|
||
text: JSON.stringify({
|
||
rows: paged.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 orgUsers = await storage.getUsersByOrganization(organizationId);
|
||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||
if (!adminUser) {
|
||
return directoryError("В организации нет пользователей для назначения createdBy");
|
||
}
|
||
|
||
const table = await storage.createDataTable({
|
||
name,
|
||
description: description ?? null,
|
||
columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })),
|
||
organizationId,
|
||
createdBy: adminUser.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),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
|
||
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 } = 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);
|
||
|
||
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 } = 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);
|
||
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);
|
||
}
|