MCP: сообщения задач, исполнители, документы, файлы, аудит — этап 2
- list_task_messages, get_task_assignees, list_document_templates, get_task_file, get_task_audit_log (read) - send_task_message, generate_document (write); set_task_assignees (full) - Логика POST /api/tasks/:id/messages вынесена в server/services/task-message.service.ts (переиспользуется route и MCP) - get_task_file: нативный S3 presigned URL или локальный путь с пояснением
This commit is contained in:
475
server/mcp.ts
475
server/mcp.ts
@@ -43,6 +43,19 @@ import {
|
||||
} from "./services/embedding.service";
|
||||
import { formatUserName } from "./utils/formatUserName";
|
||||
import { buildDataTableTree } from "./utils/data-table-tree";
|
||||
import { sendTaskMessage, SendTaskMessageError } from "./services/task-message.service";
|
||||
import { tasksMinimalCache } from "./utils/cache";
|
||||
import { evaluateAutoTransitions } from "./utils/auto-transitions";
|
||||
import { notifyTaskAssigned } from "./utils/notifyAssignee";
|
||||
import { eventBus } from "./routes/shared";
|
||||
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";
|
||||
|
||||
/** Format JS page code with consistent indentation before storing in DB. */
|
||||
function formatPageCode(code: string): string {
|
||||
@@ -119,6 +132,11 @@ const READ_TOOLS: readonly string[] = [
|
||||
'list_directory_rows',
|
||||
'get_directory_row',
|
||||
'get_directory_column_values',
|
||||
'list_task_messages',
|
||||
'get_task_assignees',
|
||||
'list_document_templates',
|
||||
'get_task_file',
|
||||
'get_task_audit_log',
|
||||
];
|
||||
|
||||
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
|
||||
@@ -128,6 +146,8 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||||
'link_tasks',
|
||||
'create_directory_row',
|
||||
'bulk_create_directory_rows',
|
||||
'send_task_message',
|
||||
'generate_document',
|
||||
];
|
||||
|
||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||
@@ -220,6 +240,9 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// Универсальная ошибка MCP-инструмента в формате { error } (русский текст)
|
||||
const mcpError = directoryError;
|
||||
|
||||
// Нормализация values строки справочника до длины массива columns:
|
||||
// лишние значения обрезаются, недостающие дополняются пустыми строками
|
||||
// (как normalizeValues в server/routes/data-tables-sync.routes.ts).
|
||||
@@ -3537,6 +3560,458 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
}
|
||||
);
|
||||
|
||||
// ── Сообщения задач ────────────────────────────────────────────────────────
|
||||
|
||||
// list_task_messages
|
||||
register(
|
||||
"list_task_messages",
|
||||
{
|
||||
title: "List Task Messages",
|
||||
description: "List messages (chat) of a task, oldest first. Use afterId for incremental polling.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
afterId: z.number().int().optional().describe("Only messages with ID greater than this (incremental polling)"),
|
||||
limit: z.number().int().min(1).max(500).optional().describe("Max messages to return (default 50)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, afterId, limit }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const messages = await storage.getTaskMessages(taskId, organizationId, afterId);
|
||||
const limited = messages.slice(0, limit ?? 50);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(limited.map((m) => ({
|
||||
id: m.id,
|
||||
message: m.message,
|
||||
messageType: m.messageType,
|
||||
authorId: m.authorId,
|
||||
author: m.author
|
||||
? (`${m.author.firstName || ''} ${m.author.middleName || ''} ${m.author.lastName || ''}`.trim() || null)
|
||||
: null,
|
||||
bot: m.bot ?? null,
|
||||
replyToMessageId: m.replyToMessageId,
|
||||
mentionedUserIds: m.mentionedUserIds,
|
||||
attachments: m.attachments,
|
||||
createdAt: m.createdAt,
|
||||
})), null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// send_task_message
|
||||
register(
|
||||
"send_task_message",
|
||||
{
|
||||
title: "Send Task Message",
|
||||
description:
|
||||
"Post a comment message to a task chat. Triggers the same side effects as the web UI: " +
|
||||
"notifications, SSE events, webhooks, embeddings. The message is authored by the first admin user of the organization.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
content: z.string().min(1).describe("Message text"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, content }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
if (!content.trim()) return mcpError("Текст сообщения обязателен");
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей для авторства сообщения");
|
||||
|
||||
try {
|
||||
const created = await sendTaskMessage({
|
||||
task,
|
||||
user: adminUser,
|
||||
organizationId,
|
||||
message: content,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: {
|
||||
id: created.id,
|
||||
taskId: created.taskId,
|
||||
authorId: created.authorId,
|
||||
message: created.message,
|
||||
messageType: created.messageType,
|
||||
createdAt: created.createdAt,
|
||||
},
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SendTaskMessageError) return mcpError(err.message);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return mcpError(`Ошибка отправки сообщения: ${msg}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Исполнители задачи ─────────────────────────────────────────────────────
|
||||
|
||||
// get_task_assignees
|
||||
register(
|
||||
"get_task_assignees",
|
||||
{
|
||||
title: "Get Task Assignees",
|
||||
description: "List assignees (исполнители) of a task with user id, name and email",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
},
|
||||
},
|
||||
async ({ taskId }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const assignees = await storage.getTaskAssignees(taskId, organizationId);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
taskId,
|
||||
assignedTo: task.assignedTo,
|
||||
assignees: assignees.map((a) => ({
|
||||
userId: a.userId,
|
||||
name: (`${a.user.firstName || ''} ${a.user.lastName || ''}`.trim()) || null,
|
||||
email: a.user.email,
|
||||
})),
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// set_task_assignees
|
||||
register(
|
||||
"set_task_assignees",
|
||||
{
|
||||
title: "Set Task Assignees",
|
||||
description:
|
||||
"Replace the full list of task assignees (исполнители). " +
|
||||
"The legacy assignedTo field is synced to the first user in the list (or null when empty). " +
|
||||
"Triggers auto-transitions, audit log entry and notifications, like the web UI.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
userIds: z.array(z.number().int()).describe("Full new list of assignee user IDs (empty array to clear all)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, userIds }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const orgUserIds = new Set(orgUsers.map((u) => u.id));
|
||||
const uniqueIds = [...new Set(userIds)];
|
||||
const invalid = uniqueIds.filter((id) => !orgUserIds.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
|
||||
// Полная замена списка: удаляем лишних, добавляем недостающих
|
||||
const current = await storage.getTaskAssignees(taskId, organizationId);
|
||||
const currentIds = current.map((a) => a.userId);
|
||||
const toAdd = uniqueIds.filter((id) => !currentIds.includes(id));
|
||||
const toRemove = currentIds.filter((id) => !uniqueIds.includes(id));
|
||||
for (const id of toRemove) {
|
||||
await storage.removeTaskAssignee(taskId, id, organizationId);
|
||||
}
|
||||
for (const id of toAdd) {
|
||||
await storage.addTaskAssignee(taskId, id, organizationId);
|
||||
}
|
||||
|
||||
// Синхронизация legacy-поля assignedTo (как в route assignees: первый из списка или null)
|
||||
const newAssignedTo = uniqueIds[0] ?? null;
|
||||
await storage.updateTask(taskId, organizationId, { assignedTo: newAssignedTo });
|
||||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser?.id ?? null });
|
||||
|
||||
const editorName = adminUser
|
||||
? (`${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP')
|
||||
: 'MCP';
|
||||
const names = uniqueIds
|
||||
.map((id) => {
|
||||
const u = orgUsers.find((x) => x.id === id);
|
||||
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(id);
|
||||
})
|
||||
.join(', ');
|
||||
storage.addTaskAuditLog({
|
||||
taskId,
|
||||
organizationId,
|
||||
action: 'task.updated',
|
||||
fieldName: 'Ответственные обновлены',
|
||||
oldValue: null,
|
||||
newValue: names || '(пусто)',
|
||||
changedBy: adminUser?.id ?? null,
|
||||
changedByName: editorName,
|
||||
}).catch((e: unknown) => { console.error('Audit log error (MCP set_task_assignees):', e); });
|
||||
|
||||
const refreshedTask = await storage.getTask(taskId, organizationId);
|
||||
|
||||
// Уведомление новому основному ответственному (если сменился)
|
||||
if (refreshedTask && newAssignedTo && newAssignedTo !== task.assignedTo) {
|
||||
notifyTaskAssigned(refreshedTask, newAssignedTo, adminUser?.id ?? null, organizationId)
|
||||
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
organizationId,
|
||||
data: { taskId, formId: refreshedTask?.formId, task: refreshedTask },
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
taskId,
|
||||
assignedTo: newAssignedTo,
|
||||
assignees: uniqueIds,
|
||||
added: toAdd,
|
||||
removed: toRemove,
|
||||
autoTransition: autoResult.changed,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Документы ──────────────────────────────────────────────────────────────
|
||||
|
||||
// list_document_templates
|
||||
register(
|
||||
"list_document_templates",
|
||||
{
|
||||
title: "List Document Templates",
|
||||
description: "List document templates of the organization with their variables. Optionally filter by folder.",
|
||||
inputSchema: {
|
||||
folderId: z.number().int().optional().describe("Filter by template folder ID (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ folderId }) => {
|
||||
const templateService = new DocumentTemplateService();
|
||||
const templates = await templateService.list({ organizationId, folderId });
|
||||
const result = await Promise.all(
|
||||
templates.map(async (t) => {
|
||||
const full = await templateService.getById(t.id, organizationId);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
folderId: t.folderId,
|
||||
categoryId: t.categoryId,
|
||||
formId: t.formId,
|
||||
status: t.status,
|
||||
variables: (full?.variables ?? []).map((v) => ({
|
||||
id: v.id,
|
||||
code: v.code,
|
||||
label: v.label,
|
||||
source: v.source,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// generate_document
|
||||
register(
|
||||
"generate_document",
|
||||
{
|
||||
title: "Generate Document",
|
||||
description:
|
||||
"Generate a document (docx or pdf) from a template for a given task. " +
|
||||
"Returns the generation ID and a download URL (requires user authorization to download).",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task to fill the template with"),
|
||||
templateId: z.number().int().describe("The numeric ID of the document template"),
|
||||
format: z.enum(["docx", "pdf"]).describe("Output format"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, templateId, format }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей для генерации документа");
|
||||
|
||||
// Сервис генерации собирается так же, как в server/documents/routes.ts
|
||||
const templateService = new DocumentTemplateService();
|
||||
const dataResolution = new DataResolutionService();
|
||||
const assetService = new AssetService();
|
||||
const generationService = new DocumentGenerationService(templateService, dataResolution, assetService);
|
||||
|
||||
try {
|
||||
const result = await generationService.generate({
|
||||
templateId,
|
||||
taskId,
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
outputFormat: format,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
generationId: result.generationId,
|
||||
downloadUrl: `/api/documents/generations/${result.generationId}/download/${format}`,
|
||||
pdfUrl: result.pdfUrl ?? null,
|
||||
docxUrl: result.docxUrl ?? null,
|
||||
status: result.status,
|
||||
unresolvedVariables: result.unresolvedVariables,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return mcpError(`Ошибка генерации документа: ${msg}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Файлы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// get_task_file
|
||||
register(
|
||||
"get_task_file",
|
||||
{
|
||||
title: "Get Task File",
|
||||
description:
|
||||
"Get a download URL for a file attached to a task (by fileKey from message attachments or file field values). " +
|
||||
"In S3/MinIO mode returns a presigned URL (valid ~5 minutes). In local mode returns a direct path that requires user authorization.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
fileKey: z.string().min(1).describe("File key (the part after /api/files/ or /uploads/ in the attachment URL)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, fileKey }) => {
|
||||
if (!fileKey || fileKey.includes('..') || fileKey.includes('/')) {
|
||||
return mcpError("Неверный ключ файла");
|
||||
}
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
// Файл должен отслеживаться в file_uploads и принадлежать организации (как canAccessFile в index.ts)
|
||||
const [upload] = await db
|
||||
.select()
|
||||
.from(fileUploads)
|
||||
.where(eq(fileUploads.fileKey, fileKey))
|
||||
.limit(1);
|
||||
if (!upload) return mcpError("Файл не найден или не отслеживается");
|
||||
if (upload.organizationId !== organizationId) {
|
||||
return mcpError("Файл принадлежит другой организации");
|
||||
}
|
||||
|
||||
// Проверка принадлежности файла именно этой задаче:
|
||||
// напрямую (file_uploads.taskId), через вложения сообщений или через значения file-полей
|
||||
let belongsToTask = upload.taskId === taskId;
|
||||
if (!belongsToTask) {
|
||||
const messages = await storage.getTaskMessages(taskId, organizationId);
|
||||
belongsToTask = messages.some((m) =>
|
||||
Array.isArray(m.attachments) &&
|
||||
m.attachments.some((a) => typeof a?.url === 'string' && a.url.includes(fileKey))
|
||||
);
|
||||
}
|
||||
if (!belongsToTask) {
|
||||
const fieldValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||||
belongsToTask = fieldValues.some((fv) => typeof fv.value === 'string' && fv.value.includes(fileKey));
|
||||
}
|
||||
if (!belongsToTask) {
|
||||
return mcpError(`Файл не относится к задаче ${taskId}`);
|
||||
}
|
||||
|
||||
if (isS3Enabled) {
|
||||
const presignedUrl = await getPresignedUrl(fileKey, 300);
|
||||
if (presignedUrl) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
fileKey,
|
||||
originalName: upload.originalName,
|
||||
downloadUrl: presignedUrl,
|
||||
type: "presigned",
|
||||
expiresInSeconds: 300,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Локальный режим (или ошибка presigned): отдаём прямой путь с пояснением
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
fileKey,
|
||||
originalName: upload.originalName,
|
||||
downloadUrl: isS3Enabled ? `/api/files/${fileKey}` : `/uploads/${fileKey}`,
|
||||
type: "direct",
|
||||
note: "Presigned URL недоступен (локальный режим хранения). Ссылка требует авторизации пользователя (JWT/сессия); временную ссылку выдаёт GET /api/files/:key/presigned.",
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Аудит задачи ───────────────────────────────────────────────────────────
|
||||
|
||||
// get_task_audit_log
|
||||
register(
|
||||
"get_task_audit_log",
|
||||
{
|
||||
title: "Get Task Audit Log",
|
||||
description: "Get the audit log entries of a task (field changes, status changes, etc.), newest first",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().describe("The numeric ID of the task"),
|
||||
limit: z.number().int().min(1).max(500).optional().describe("Max entries to return (default 50)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, limit }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
|
||||
const entries = await storage.getTaskAuditLog(taskId, organizationId);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(entries.slice(0, limit ?? 50).map((e) => ({
|
||||
id: e.id,
|
||||
action: e.action,
|
||||
fieldName: e.fieldName,
|
||||
oldValue: e.oldValue,
|
||||
newValue: e.newValue,
|
||||
changedBy: e.changedBy,
|
||||
changedByName: e.changedByName,
|
||||
createdAt: e.createdAt,
|
||||
})), null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user