From 25f84ee532c21cdafaaa94e13560148be1419cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 21 Jul 2026 17:30:50 +0300 Subject: [PATCH] =?UTF-8?q?MCP:=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F,=20inbox,=20=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D0=B3=D0=B0=D1=86=D0=B8=D0=B8,=20update=5Ffo?= =?UTF-8?q?rm,=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87,=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9,?= =?UTF-8?q?=20DaData=20=E2=80=94=20=D1=8D=D1=82=D0=B0=D0=BF=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read: list_task_reminders, list_notifications, get_inbox, aggregate_tasks, get_task_tree, get_user_field_values, dadata_suggest - write: set_task_reminder, send_notification, mark_notifications_read - full: delete_task_reminder, update_form, set_user_field_value --- server/mcp.ts | 714 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 709 insertions(+), 5 deletions(-) diff --git a/server/mcp.ts b/server/mcp.ts index bdad8d7..10665a6 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -44,18 +44,20 @@ import { import { formatUserName } from "./utils/formatUserName"; import { buildDataTableTree } from "./utils/data-table-tree"; import { sendTaskMessage, SendTaskMessageError } from "./services/task-message.service"; -import { tasksMinimalCache } from "./utils/cache"; +import { tasksMinimalCache, formsCache } from "./utils/cache"; import { evaluateAutoTransitions } from "./utils/auto-transitions"; import { notifyTaskAssigned } from "./utils/notifyAssignee"; -import { eventBus } from "./routes/shared"; +import { eventBus, publishNotificationSSE } from "./routes/shared"; +import { indexFormAsync } from "./routes/task-helpers"; import { DocumentTemplateService } from "./documents/template.service"; import { DocumentGenerationService } from "./documents/generation.service"; import { DataResolutionService } from "./documents/data-resolution.service"; import { AssetService } from "./documents/asset.service"; import { isS3Enabled, getPresignedUrl } from "./utils/s3"; -import { db } from "./db"; -import { fileUploads } from "@shared/schema"; -import { eq } from "drizzle-orm"; +import { db, pool } from "./db"; +import { fileUploads, taskReminders } from "@shared/schema"; +import { eq, and } from "drizzle-orm"; +import { decrypt as decryptSecret } from "./crypto"; /** Format JS page code with consistent indentation before storing in DB. */ function formatPageCode(code: string): string { @@ -137,6 +139,13 @@ const READ_TOOLS: readonly string[] = [ 'list_document_templates', 'get_task_file', 'get_task_audit_log', + 'list_task_reminders', + 'list_notifications', + 'get_inbox', + 'aggregate_tasks', + 'get_task_tree', + 'get_user_field_values', + 'dadata_suggest', ]; // WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных. @@ -148,6 +157,9 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [ 'bulk_create_directory_rows', 'send_task_message', 'generate_document', + 'set_task_reminder', + 'send_notification', + 'mark_notifications_read', ]; // Все остальные инструменты (изменение/удаление форм, задач, пользователей, @@ -4012,6 +4024,698 @@ To block task creation from task.before_create, set: ctx.result = { allow: false } ); + // ── Напоминания по задачам ───────────────────────────────────────────────── + + // list_task_reminders + register( + "list_task_reminders", + { + title: "List Task Reminders", + description: "List pending (not yet sent) reminders of a task", + inputSchema: { + taskId: z.number().int().describe("The numeric ID of the task"), + }, + }, + async ({ taskId }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const reminders = await storage.getTaskReminders(taskId, organizationId); + return { + content: [{ + type: "text" as const, + text: JSON.stringify(reminders.map((r) => ({ + id: r.id, + taskId: r.taskId, + remindAt: r.remindAt, + note: r.note, + recipients: r.recipients, + isSent: r.isSent, + createdByUserId: r.createdByUserId, + createdAt: r.createdAt, + })), null, 2), + }], + }; + } + ); + + // set_task_reminder + register( + "set_task_reminder", + { + title: "Set Task Reminder", + description: "Create a reminder for a task addressed to a single user. remindAt must be an ISO 8601 date-time.", + inputSchema: { + taskId: z.number().int().describe("The numeric ID of the task"), + userId: z.number().int().describe("Recipient user ID (must belong to the organization)"), + remindAt: z.string().describe("Reminder date-time in ISO 8601 format"), + message: z.string().optional().describe("Optional note shown with the reminder"), + }, + }, + async ({ taskId, userId, remindAt, message }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const remindAtDate = new Date(remindAt); + if (isNaN(remindAtDate.getTime())) { + return mcpError("Неверный формат даты напоминания"); + } + + const orgUsers = await storage.getUsersByOrganization(organizationId); + const recipient = orgUsers.find((u) => u.id === userId); + if (!recipient) { + return mcpError(`Пользователь ${userId} не принадлежит организации`); + } + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + if (!adminUser) return mcpError("В организации нет пользователей"); + + const reminder = await storage.createTaskReminder({ + taskId, + organizationId, + createdByUserId: adminUser.id, + remindAt: remindAtDate, + note: message ?? null, + recipients: [{ type: "user", userId }], + }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, reminder }, null, 2), + }], + }; + } + ); + + // delete_task_reminder + register( + "delete_task_reminder", + { + title: "Delete Task Reminder", + description: "Delete a task reminder by its ID", + inputSchema: { + reminderId: z.number().int().describe("The numeric ID of the reminder"), + }, + }, + async ({ reminderId }) => { + // Напоминание → задача → проверка доступа к форме задачи + const [reminder] = await db + .select() + .from(taskReminders) + .where(and(eq(taskReminders.id, reminderId), eq(taskReminders.organizationId, organizationId))) + .limit(1); + if (!reminder) return mcpError(`Напоминание ${reminderId} не найдено`); + + const task = await storage.getTask(reminder.taskId, organizationId); + if (task && !isFormAllowed(task.formId)) return formDenied(task.formId); + + await storage.deleteTaskReminder(reminderId, organizationId); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted: { id: reminderId } }, null, 2) }], + }; + } + ); + + // ── Уведомления ──────────────────────────────────────────────────────────── + + // list_notifications + register( + "list_notifications", + { + title: "List Notifications", + description: "List notifications of a user, newest first", + inputSchema: { + userId: z.number().int().describe("The numeric ID of the user"), + limit: z.number().int().min(1).max(200).optional().describe("Max notifications to return (default 50)"), + }, + }, + async ({ userId, limit }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + const items = await storage.getUserNotifications(userId, organizationId); + return { + content: [{ + type: "text" as const, + text: JSON.stringify(items.slice(0, limit ?? 50).map((n) => ({ + id: n.id, + type: n.type, + title: n.title, + message: n.message, + taskId: n.taskId, + messageId: n.messageId, + isRead: n.isRead, + createdAt: n.createdAt, + })), null, 2), + }], + }; + } + ); + + // send_notification + register( + "send_notification", + { + title: "Send Notification", + description: + "Create an in-app notification for a user (type 'system') and push a realtime SSE event, " + + "so the user's notification badge updates immediately. " + + "Note: the schema has no link field — the optional link is appended to the message text.", + inputSchema: { + userId: z.number().int().describe("Recipient user ID"), + title: z.string().min(1).describe("Notification title"), + message: z.string().min(1).describe("Notification text"), + link: z.string().optional().describe("Optional URL (appended to the message text)"), + }, + }, + async ({ userId, title, message, link }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + // В схеме user_notifications нет поля link — добавляем ссылку в текст + const notification = await storage.createUserNotification({ + userId, + organizationId, + type: "system", + title, + message: link ? `${message}\n${link}` : message, + isRead: false, + }); + // Realtime-обновление бейджа уведомлений у пользователя (как воркер напоминаний в index.ts) + publishNotificationSSE(userId, organizationId, { type: "system", notificationId: notification.id }); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, notification: { id: notification.id, userId, title: notification.title, isRead: notification.isRead, createdAt: notification.createdAt } }, null, 2), + }], + }; + } + ); + + // mark_notifications_read + register( + "mark_notifications_read", + { + title: "Mark Notifications Read", + description: "Mark a single notification (by notificationId) or all notifications of a user as read", + inputSchema: { + userId: z.number().int().describe("The numeric ID of the user"), + notificationId: z.number().int().optional().describe("Notification ID to mark as read. Omit to mark ALL user notifications as read."), + }, + }, + async ({ userId, notificationId }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + if (notificationId !== undefined) { + const updated = await storage.markNotificationAsRead(notificationId, userId, organizationId); + if (!updated) return mcpError(`Уведомление ${notificationId} не найдено у пользователя ${userId}`); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: 1 }, null, 2) }], + }; + } + await storage.markAllNotificationsAsRead(userId, organizationId); + return { + content: [{ type: "text" as const, text: JSON.stringify({ success: true, marked: "all" }, null, 2) }], + }; + } + ); + + // ── Входящие (inbox) ─────────────────────────────────────────────────────── + + // get_inbox + register( + "get_inbox", + { + title: "Get Inbox", + description: + "Get the inbox of a user: uncompleted tasks requiring their attention " + + "(assigned, mentioned in unread notifications, or via task roles), with reasons and unread counts. " + + "Mirrors GET /api/home/inbox with default filters. total is computed after API-key form filtering.", + inputSchema: { + userId: z.number().int().describe("The numeric ID of the user"), + limit: z.number().int().min(1).max(100).optional().describe("Max tasks to return (default 50)"), + }, + }, + async ({ userId, limit }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + const limitVal = Math.min(limit ?? 50, 100); + + // SQL повторяет GET /api/home/inbox (home.routes.ts) с фильтрами по умолчанию: + // без search/status/assignee/overdue, сортировка t.updated_at DESC. + // Выбираем с запасом (500), затем фильтруем по scopes.formIds и режем до limit. + const result = await pool.query(` + WITH inbox_sources AS ( + SELECT t.id AS task_id, 'assignee' AS reason, NULL::text AS role_name + FROM tasks t + WHERE t.organization_id = $1 + AND t.assigned_to = $2 + AND t.is_completed = false + + UNION + + SELECT ta.task_id, 'assignee' AS reason, NULL::text AS role_name + FROM task_assignees ta + JOIN tasks t ON t.id = ta.task_id AND t.organization_id = $1 AND t.is_completed = false + WHERE ta.user_id = $2 AND ta.organization_id = $1 + + UNION + + SELECT un.task_id, 'notification' AS reason, NULL::text + FROM user_notifications un + JOIN tasks t2 ON t2.id = un.task_id AND t2.organization_id = $1 AND t2.is_completed = false + WHERE un.organization_id = $1 + AND un.user_id = $2 + AND un.is_read = false + AND un.task_id IS NOT NULL + + UNION + + SELECT tr.task_id, 'role' AS reason, r.name AS role_name + FROM task_roles tr + JOIN role_members rm ON rm.role_id = tr.role_id AND rm.user_id = $2 + JOIN roles r ON r.id = tr.role_id + JOIN tasks t3 ON t3.id = tr.task_id AND t3.organization_id = $1 AND t3.is_completed = false + WHERE tr.organization_id = $1 + ), + aggregated AS ( + SELECT + task_id, + ARRAY_AGG(DISTINCT reason) AS reasons, + ARRAY_AGG(DISTINCT role_name) FILTER (WHERE role_name IS NOT NULL) AS role_names + FROM inbox_sources + GROUP BY task_id + ) + SELECT + t.id, + t.title, + t.form_id AS "formId", + f.name AS "formName", + fs.name AS "statusName", + fs.color AS "statusColor", + fs.is_final AS "statusIsFinal", + t.current_status_id AS "statusId", + COALESCE( + CASE WHEN u.id IS NOT NULL THEN CONCAT(u.first_name, ' ', u.last_name) ELSE NULL END, + (SELECT STRING_AGG(CONCAT(ua.first_name, ' ', ua.last_name), ', ' ORDER BY ta2.id) + FROM task_assignees ta2 JOIN users ua ON ua.id = ta2.user_id + WHERE ta2.task_id = t.id) + ) AS "assigneeName", + t.assigned_to AS "assignedTo", + t.created_at AS "createdAt", + t.updated_at AS "updatedAt", + t.due_date AS "dueDate", + t.is_completed AS "isCompleted", + a.reasons, + a.role_names AS "roleNames", + ( + SELECT COUNT(*) FROM user_notifications un2 + WHERE un2.task_id = t.id AND un2.user_id = $2 AND un2.is_read = false + )::int AS "unreadNotifications" + FROM aggregated a + JOIN tasks t ON t.id = a.task_id AND t.organization_id = $1 + JOIN forms f ON f.id = t.form_id + JOIN form_statuses fs ON fs.id = t.current_status_id + LEFT JOIN users u ON u.id = t.assigned_to + WHERE t.is_completed = false + AND ($3 = '' OR t.title ILIKE '%' || $3 || '%') + AND ($4 = 0 OR t.form_id = $4) + AND ($7 = '' OR fs.name ILIKE '%' || $7 || '%') + AND ($8 = '' OR COALESCE(CONCAT(u.first_name, ' ', u.last_name), '') ILIKE '%' || $8 || '%' + OR EXISTS (SELECT 1 FROM task_assignees ta_f JOIN users ua_f ON ua_f.id = ta_f.user_id + WHERE ta_f.task_id = t.id AND CONCAT(ua_f.first_name, ' ', ua_f.last_name) ILIKE '%' || $8 || '%')) + AND (NOT $9 OR (t.due_date IS NOT NULL AND t.due_date < NOW())) + AND NOT EXISTS ( + SELECT 1 FROM task_reminders tr_snooze + WHERE tr_snooze.task_id = t.id + AND tr_snooze.created_by_user_id = $2 + AND tr_snooze.remind_at > NOW() + AND tr_snooze.is_sent = false + ) + AND NOT EXISTS ( + SELECT 1 FROM task_inbox_hidden tih + WHERE tih.task_id = t.id AND tih.user_id = $2 + ) + ORDER BY t.updated_at DESC + LIMIT $5 OFFSET $6 + `, [organizationId, userId, '', 0, 500, 0, '', '', false]); + + // Фильтруем выдачу по scopes.formIds (null = все формы) + const rows = (result.rows as Array<{ formId: number }>).filter((r) => isFormAllowed(r.formId)); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + tasks: rows.slice(0, limitVal), + total: rows.length, + }, null, 2), + }], + }; + } + ); + + // ── Агрегации задач ──────────────────────────────────────────────────────── + + // aggregate_tasks + register( + "aggregate_tasks", + { + title: "Aggregate Tasks", + description: + "Compute aggregations over a numeric task field of a form (mirrors POST /api/forms/:id/tasks/aggregate). " + + "Each aggregation: { fieldId, fn } where fn is count|sum|avg|min|max. " + + "count = number of non-empty numeric values. Optionally filter tasks by statusId.", + inputSchema: { + formId: z.number().int().describe("The numeric ID of the form"), + aggregations: z.array(z.object({ + fieldId: z.number().int().describe("Field ID whose numeric values are aggregated"), + fn: z.enum(["count", "sum", "avg", "min", "max"]).describe("Aggregation function"), + })).min(1).describe("Aggregations to compute"), + statusId: z.number().int().optional().describe("Only tasks in this status (optional)"), + }, + }, + async ({ formId, aggregations, statusId }) => { + if (!isFormAllowed(formId)) return formDenied(formId); + const form = await storage.getForm(formId, organizationId); + if (!form) return mcpError(`Форма ${formId} не найдена`); + + // Фильтр доступа как в endpoint — от имени первого админа (админ видит все задачи) + const orgUsers = await storage.getUsersByOrganization(organizationId); + const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0]; + const [result, accessibleIds] = await Promise.all([ + storage.getTasksWithFieldsByFormOptimized(formId, organizationId, {}), + adminUser + ? storage.getAccessibleTaskIds(adminUser.id, organizationId, adminUser.appRole) + : Promise.resolve(null), + ]); + type TaskWithFields = (typeof result.tasks)[number] & { fieldValues?: Record }; + let tasks = result.tasks as TaskWithFields[]; + if (accessibleIds !== null) { + tasks = tasks.filter((t) => accessibleIds.has(t.id)); + } + if (statusId) { + tasks = tasks.filter((t) => t.currentStatusId === statusId); + } + + const results = aggregations.map(({ fieldId, fn }) => { + const values: number[] = []; + for (const task of tasks) { + const fieldValue = task.fieldValues?.[fieldId]; + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + const numValue = parseFloat(String(fieldValue)); + if (!isNaN(numValue)) values.push(numValue); + } + } + let value: number | null = null; + switch (fn) { + case 'sum': + value = values.reduce((a, b) => a + b, 0); + break; + case 'avg': + value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + break; + case 'count': + value = values.length; + break; + case 'min': + value = values.length > 0 ? Math.min(...values) : null; + break; + case 'max': + value = values.length > 0 ? Math.max(...values) : null; + break; + } + return { fieldId, fn, value, valuesCount: values.length }; + }); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, formId, taskCount: tasks.length, results }, null, 2), + }], + }; + } + ); + + // ── Обновление формы ─────────────────────────────────────────────────────── + + // update_form + register( + "update_form", + { + title: "Update Form", + description: + "Update safe form properties: name, description, visibility, qrGenerationEnabled, folderId. " + + "Does NOT touch fields, statuses or title template.", + inputSchema: { + formId: z.number().int().describe("The numeric ID of the form"), + name: z.string().min(1).optional().describe("New form name"), + description: z.string().nullable().optional().describe("New description (null to clear)"), + visibility: z.enum(["organization", "restricted"]).optional().describe("'organization' = visible to all org members, 'restricted' = access rules only"), + qrGenerationEnabled: z.boolean().optional().describe("Show QR generation button in the form registry"), + folderId: z.number().int().nullable().optional().describe("Move form to folder (null = root)"), + }, + }, + async ({ formId, name, description, visibility, qrGenerationEnabled, folderId }) => { + if (!isFormAllowed(formId)) return formDenied(formId); + const existingForm = await storage.getForm(formId, organizationId); + if (!existingForm) return mcpError(`Форма ${formId} не найдена`); + + const updates: Record = {}; + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + if (visibility !== undefined) updates.visibility = visibility; + if (qrGenerationEnabled !== undefined) updates.qrGenerationEnabled = qrGenerationEnabled; + if (folderId !== undefined) updates.folderId = folderId; + if (Object.keys(updates).length === 0) { + return mcpError("Не переданы данные для обновления"); + } + + const updatedForm = await storage.updateForm(formId, organizationId, updates); + // Кэш и RAG-переиндексация — как в PUT /api/forms/:id + formsCache.invalidatePrefix(`forms:${organizationId}`); + indexFormAsync(formId, organizationId).catch(() => {}); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + success: true, + form: { + id: updatedForm.id, + name: updatedForm.name, + description: updatedForm.description, + visibility: updatedForm.visibility, + qrGenerationEnabled: updatedForm.qrGenerationEnabled, + folderId: updatedForm.folderId, + }, + }, null, 2), + }], + }; + } + ); + + // ── Дерево задач ─────────────────────────────────────────────────────────── + + // get_task_tree + register( + "get_task_tree", + { + title: "Get Task Tree", + description: + "Get the hierarchy of a task: parent chain (via parentTaskId, up to 10 levels) and the recursive subtasks tree. " + + "Nodes from forms not allowed by the API key are filtered out.", + inputSchema: { + taskId: z.number().int().describe("The numeric ID of the task"), + }, + }, + async ({ taskId }) => { + const task = await storage.getTask(taskId, organizationId); + if (!task) return mcpError(`Задача ${taskId} не найдена`); + if (!isFormAllowed(task.formId)) return formDenied(task.formId); + + const tree = await storage.getTaskTree(taskId, organizationId); + + // Компактный маппинг узлов с отсечением поддеревьев недоступных форм + const mapNode = (node: Record | null): unknown => { + if (!node || typeof node.formId !== 'number' || !isFormAllowed(node.formId)) return null; + const subtasks = Array.isArray(node.subtasks) ? node.subtasks : []; + return { + id: node.id, + title: node.title, + formId: node.formId, + currentStatusId: node.currentStatusId, + isCompleted: node.isCompleted, + dueDate: node.dueDate, + subtasks: subtasks.map((s) => mapNode(s as Record)).filter(Boolean), + }; + }; + + // Цепочка родителей вверх по parentTaskId (с защитой от циклов) + const parents: Array> = []; + const seen = new Set([task.id]); + let cursor = task.parentTaskId ?? null; + while (cursor !== null && !seen.has(cursor) && parents.length < 10) { + seen.add(cursor); + const parent = await storage.getTask(cursor, organizationId); + if (!parent) break; + if (isFormAllowed(parent.formId)) { + parents.push({ + id: parent.id, + title: parent.title, + formId: parent.formId, + currentStatusId: parent.currentStatusId, + isCompleted: parent.isCompleted, + }); + } + cursor = parent.parentTaskId ?? null; + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, task: mapNode(tree), parents }, null, 2), + }], + }; + } + ); + + // ── Кастомные поля пользователей ─────────────────────────────────────────── + + // get_user_field_values + register( + "get_user_field_values", + { + title: "Get User Field Values", + description: "Get custom field values of a user, enriched with field codes and names", + inputSchema: { + userId: z.number().int().describe("The numeric ID of the user"), + }, + }, + async ({ userId }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + const [fields, values] = await Promise.all([ + storage.getUserCustomFields(organizationId), + storage.getUserCustomValues(userId, organizationId), + ]); + const fieldMap = new Map(fields.map((f) => [f.id, f])); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + userId, + values: values.map((v) => ({ + fieldId: v.fieldId, + fieldCode: fieldMap.get(v.fieldId)?.code ?? null, + fieldName: fieldMap.get(v.fieldId)?.name ?? null, + value: v.value, + })), + }, null, 2), + }], + }; + } + ); + + // set_user_field_value + register( + "set_user_field_value", + { + title: "Set User Field Value", + description: "Set a custom field value for a user by field code. Use list_users or get_user_field_values to discover field codes.", + inputSchema: { + userId: z.number().int().describe("The numeric ID of the user"), + fieldCode: z.string().min(1).describe("Custom field code"), + value: z.string().nullable().describe("New value (null to clear)"), + }, + }, + async ({ userId, fieldCode, value }) => { + const user = await storage.getUser(userId); + if (!user || user.organizationId !== organizationId) { + return mcpError(`Пользователь ${userId} не найден в организации`); + } + const fields = await storage.getUserCustomFields(organizationId); + const field = fields.find((f) => f.code === fieldCode); + if (!field) { + return mcpError(`Поле с кодом "${fieldCode}" не найдено. Доступные коды: ${fields.map((f) => f.code).join(', ') || '(нет)'}`); + } + await storage.setUserCustomValues(userId, [{ fieldId: field.id, value }], organizationId); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, userId, fieldCode, value }, null, 2), + }], + }; + } + ); + + // ── DaData подсказки ─────────────────────────────────────────────────────── + + // dadata_suggest + register( + "dadata_suggest", + { + title: "DaData Suggest", + description: + "Get DaData suggestions for organizations ('party') or addresses ('address'). " + + "Uses the organization DaData API key, falling back to the global DADATA_API_KEY env variable.", + inputSchema: { + type: z.enum(["party", "address"]).describe("'party' = organizations, 'address' = addresses"), + query: z.string().min(1).describe("Search query"), + count: z.number().int().min(1).max(20).optional().describe("Max suggestions (default 10, max 20)"), + }, + }, + async ({ type, query, count }) => { + // Ключ: сначала org-specific сервис, затем глобальный env (как в external.routes.ts) + const orgService = await storage.getExternalServiceByType('dadata', organizationId); + let apiKey: string | null = null; + if (orgService && orgService.apiKey) { + apiKey = decryptSecret(orgService.apiKey); + } else { + apiKey = process.env.DADATA_API_KEY || null; + } + if (!apiKey) { + return mcpError('API-ключ DaData не настроен. Добавьте его в разделе "Доступы к сервисам".'); + } + + try { + const response = await fetch(`https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/${type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Token ${apiKey}`, + }, + body: JSON.stringify({ query, count: Math.min(count ?? 10, 20) }), + }); + if (!response.ok) { + const errorText = await response.text(); + console.error('Dadata API error (MCP):', errorText); + return mcpError("Ошибка API DaData"); + } + const data = await response.json(); + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ success: true, suggestions: data.suggestions }, null, 2), + }], + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return mcpError(`Ошибка при запросе к DaData: ${msg}`); + } + } + ); + return server; }