MCP: напоминания, уведомления, inbox, агрегации, update_form, дерево задач, поля пользователей, DaData — этап 3

- 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
This commit is contained in:
2026-07-21 17:30:50 +03:00
parent 05e09fa08b
commit 25f84ee532

View File

@@ -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<number, string | number | null> };
let tasks = result.tasks as TaskWithFields[];
if (accessibleIds !== null) {
tasks = tasks.filter((t) => accessibleIds.has(t.id));
}
if (statusId) {
tasks = tasks.filter((t) => t.currentStatusId === statusId);
}
const results = aggregations.map(({ fieldId, fn }) => {
const values: number[] = [];
for (const task of tasks) {
const fieldValue = task.fieldValues?.[fieldId];
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
const numValue = parseFloat(String(fieldValue));
if (!isNaN(numValue)) values.push(numValue);
}
}
let value: number | null = null;
switch (fn) {
case 'sum':
value = values.reduce((a, b) => a + b, 0);
break;
case 'avg':
value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case 'count':
value = values.length;
break;
case 'min':
value = values.length > 0 ? Math.min(...values) : null;
break;
case 'max':
value = values.length > 0 ? Math.max(...values) : null;
break;
}
return { fieldId, fn, value, valuesCount: values.length };
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({ success: true, formId, taskCount: tasks.length, results }, null, 2),
}],
};
}
);
// ── Обновление формы ───────────────────────────────────────────────────────
// update_form
register(
"update_form",
{
title: "Update Form",
description:
"Update safe form properties: name, description, visibility, qrGenerationEnabled, folderId. " +
"Does NOT touch fields, statuses or title template.",
inputSchema: {
formId: z.number().int().describe("The numeric ID of the form"),
name: z.string().min(1).optional().describe("New form name"),
description: z.string().nullable().optional().describe("New description (null to clear)"),
visibility: z.enum(["organization", "restricted"]).optional().describe("'organization' = visible to all org members, 'restricted' = access rules only"),
qrGenerationEnabled: z.boolean().optional().describe("Show QR generation button in the form registry"),
folderId: z.number().int().nullable().optional().describe("Move form to folder (null = root)"),
},
},
async ({ formId, name, description, visibility, qrGenerationEnabled, folderId }) => {
if (!isFormAllowed(formId)) return formDenied(formId);
const existingForm = await storage.getForm(formId, organizationId);
if (!existingForm) return mcpError(`Форма ${formId} не найдена`);
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = name;
if (description !== undefined) updates.description = description;
if (visibility !== undefined) updates.visibility = visibility;
if (qrGenerationEnabled !== undefined) updates.qrGenerationEnabled = qrGenerationEnabled;
if (folderId !== undefined) updates.folderId = folderId;
if (Object.keys(updates).length === 0) {
return mcpError("Не переданы данные для обновления");
}
const updatedForm = await storage.updateForm(formId, organizationId, updates);
// Кэш и RAG-переиндексация — как в PUT /api/forms/:id
formsCache.invalidatePrefix(`forms:${organizationId}`);
indexFormAsync(formId, organizationId).catch(() => {});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
form: {
id: updatedForm.id,
name: updatedForm.name,
description: updatedForm.description,
visibility: updatedForm.visibility,
qrGenerationEnabled: updatedForm.qrGenerationEnabled,
folderId: updatedForm.folderId,
},
}, null, 2),
}],
};
}
);
// ── Дерево задач ───────────────────────────────────────────────────────────
// get_task_tree
register(
"get_task_tree",
{
title: "Get Task Tree",
description:
"Get the hierarchy of a task: parent chain (via parentTaskId, up to 10 levels) and the recursive subtasks tree. " +
"Nodes from forms not allowed by the API key are filtered out.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
},
},
async ({ taskId }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const tree = await storage.getTaskTree(taskId, organizationId);
// Компактный маппинг узлов с отсечением поддеревьев недоступных форм
const mapNode = (node: Record<string, unknown> | null): unknown => {
if (!node || typeof node.formId !== 'number' || !isFormAllowed(node.formId)) return null;
const subtasks = Array.isArray(node.subtasks) ? node.subtasks : [];
return {
id: node.id,
title: node.title,
formId: node.formId,
currentStatusId: node.currentStatusId,
isCompleted: node.isCompleted,
dueDate: node.dueDate,
subtasks: subtasks.map((s) => mapNode(s as Record<string, unknown>)).filter(Boolean),
};
};
// Цепочка родителей вверх по parentTaskId (с защитой от циклов)
const parents: Array<Record<string, unknown>> = [];
const seen = new Set<number>([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;
}