MCP: загрузка файлов (base64) в file-поля, комментарии, справочники и таб-таблицы задач

- server/services/file-upload.service.ts: uploadFileFromBase64 (whitelist расширений, magic bytes, лимиты 10/50 МБ, S3/MinIO или локальный диск, запись file_uploads)
- Инструменты (write/full): upload_file, upload_task_file, upload_message_file, upload_directory_file, upload_table_row_file
- Проверки доступа isFormAllowed/isTableAllowed
This commit is contained in:
2026-07-22 11:28:45 +03:00
parent 25f84ee532
commit 936bce2ba9
2 changed files with 503 additions and 1 deletions

View File

@@ -48,7 +48,8 @@ import { tasksMinimalCache, formsCache } from "./utils/cache";
import { evaluateAutoTransitions } from "./utils/auto-transitions";
import { notifyTaskAssigned } from "./utils/notifyAssignee";
import { eventBus, publishNotificationSSE } from "./routes/shared";
import { indexFormAsync } from "./routes/task-helpers";
import { indexFormAsync, indexTaskAsync } from "./routes/task-helpers";
import { uploadFileFromBase64, FileUploadError } from "./services/file-upload.service";
import { DocumentTemplateService } from "./documents/template.service";
import { DocumentGenerationService } from "./documents/generation.service";
import { DataResolutionService } from "./documents/data-resolution.service";
@@ -160,6 +161,11 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
'set_task_reminder',
'send_notification',
'mark_notifications_read',
'upload_file',
'upload_task_file',
'upload_message_file',
'upload_directory_file',
'upload_table_row_file',
];
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
@@ -4716,6 +4722,369 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}
);
// ── Загрузка файлов (base64) ───────────────────────────────────────────────
// upload_file
register(
"upload_file",
{
title: "Upload File",
description:
"Upload a file (base64) to the organization storage without attaching it anywhere. " +
"Returns {key, url, name, size, mimeType} — the url can then be placed into fields, cells or attachments manually. " +
"Limits: images 10 MB, other types 50 MB; extension whitelist and magic-bytes checks apply (same as the web UI upload).",
inputSchema: {
fileName: z.string().min(1).describe("Original file name with extension (extension must be whitelisted: images, pdf, office docs, txt/csv, zip/rar)"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix 'data:...;base64,' is allowed)"),
mimeType: z.string().optional().describe("MIME type (optional; normalized from the extension if missing or inconsistent)"),
},
},
async ({ fileName, contentBase64, mimeType }) => {
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({ success: true, file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType } }, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка загрузки файла: ${msg}`);
}
}
);
// upload_task_file
register(
"upload_task_file",
{
title: "Upload Task Field File",
description:
"Upload a file (base64) and APPEND it to a file-type form field of a task. " +
"File fields are multiple: the value is an array of {url, name, size}. " +
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
fieldId: z.number().int().describe("The numeric ID of the file-type form field"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const formFields = await storage.getFormFields(task.formId, organizationId);
const field = formFields.find((f) => f.id === fieldId);
if (!field) return mcpError(`Поле ${fieldId} не найдено в форме ${task.formId}`);
if (field.type !== 'file') {
return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`);
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
fieldId,
});
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
const existingValue = existingValues.find((v) => v.fieldId === fieldId);
const currentFiles = Array.isArray(existingValue?.value) ? existingValue.value as Array<Record<string, unknown>> : [];
const newFiles = [...currentFiles, { url: file.url, name: file.name, size: file.size }];
// Лимиты поля (как в PATCH /api/tasks/:id/field-values/:fieldId)
if (field.maxFileCount != null && newFiles.length > field.maxFileCount) {
return mcpError(`Превышено максимальное количество файлов (${field.maxFileCount})`);
}
if (field.maxFileSizeMB != null) {
const totalBytes = newFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
if (totalBytes > field.maxFileSizeMB * 1024 * 1024) {
return mcpError(`Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`);
}
}
if (existingValue) {
await storage.updateTaskFieldValue(taskId, fieldId, organizationId, { value: newFiles });
} else {
await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles });
}
const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP';
storage.addTaskAuditLog({
taskId,
organizationId,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: existingValue?.value ?? null,
newValue: newFiles,
changedBy: adminUser.id,
changedByName: editorName,
}).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); });
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id });
indexTaskAsync(taskId, organizationId).catch(() => {});
const freshTask = await storage.getTask(taskId, organizationId);
eventBus.publishEvent({
type: 'task_updated',
organizationId,
data: { taskId, formId: task.formId, task: freshTask, autoTransition: autoResult.changed },
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
fieldValue: newFiles,
}, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка загрузки файла: ${msg}`);
}
}
);
// upload_message_file
register(
"upload_message_file",
{
title: "Upload Message Attachment",
description:
"Upload a file (base64) and post it as a task comment attachment. " +
"If content is omitted, the message text is generated as '📎 <file name>'. " +
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
},
},
async ({ taskId, fileName, contentBase64, mimeType, content }) => {
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("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
});
const created = await sendTaskMessage({
task,
user: adminUser,
organizationId,
message: content?.trim() || `📎 ${file.name}`,
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
message: { id: created.id, taskId: created.taskId, message: created.message, createdAt: created.createdAt },
}, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
if (err instanceof SendTaskMessageError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка загрузки файла: ${msg}`);
}
}
);
// upload_directory_file
register(
"upload_directory_file",
{
title: "Upload Directory Cell File",
description:
"Upload a file (base64) and write a link into a directory row cell. " +
"Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
inputSchema: {
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
rowId: z.number().int().describe("The numeric ID of the row"),
columnIndex: z.number().int().min(0).describe("Column index (0-based)"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => {
if (!isTableAllowed(tableId)) return tableDenied(tableId);
const table = await storage.getDataTable(tableId, organizationId);
if (!table) return mcpError(`Справочник ${tableId} не найден`);
const columnCount = table.columns?.length ?? 0;
if (columnIndex < 0 || columnIndex >= columnCount) {
return mcpError(`Колонка ${columnIndex} вне диапазона (в справочнике ${columnCount} колонок)`);
}
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
});
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
const values = Array.isArray(row.values) ? [...row.values] : [];
while (values.length < columnCount) values.push('');
values[columnIndex] = `[${file.name}](${file.url})`;
const updated = await storage.updateDataTableRow(rowId, tableId, organizationId, { values });
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
row: { id: updated.id, values: updated.values },
}, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка загрузки файла: ${msg}`);
}
}
);
// upload_table_row_file
register(
"upload_table_row_file",
{
title: "Upload Table Tab Cell File",
description:
"Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
"The cell gets a markdown link: [file name](url). " +
"If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
tabId: z.number().int().describe("The numeric ID of the table tab"),
columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"),
rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const tab = await storage.getFormTab(tabId, task.formId, organizationId);
if (!tab) return mcpError(`Таб ${tabId} не найден в форме ${task.formId}`);
if (tab.type !== 'table') return mcpError(`Таб ${tabId} имеет тип '${tab.type}', а не 'table'`);
type ColDef = { id: string; name: string; type?: string };
const columns: ColDef[] = Array.isArray(tab.tableColumns) ? (tab.tableColumns as ColDef[]) : [];
if (!columns.some((c) => c.id === columnId)) {
return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`);
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
});
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
// пишем markdown-ссылку [имя](url), как и в справочниках
const cellValue = `[${file.name}](${file.url})`;
let row;
if (rowId !== undefined) {
const existing = await storage.getRegularTableRow(rowId, taskId, tabId);
if (!existing) return mcpError(`Строка ${rowId} не найдена в табе ${tabId}`);
const data = { ...((existing.data ?? {}) as Record<string, unknown>), [columnId]: cellValue };
row = await storage.updateRegularTableRow(rowId, taskId, tabId, { data });
} else {
row = await storage.createRegularTableRow({
taskId,
tabId,
data: { [columnId]: cellValue },
createdBy: adminUser.id,
});
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
file: { key: file.key, url: file.url, name: file.name, size: file.size, mimeType: file.mimeType },
row: { id: row.id, data: row.data },
}, null, 2),
}],
};
} catch (err: unknown) {
if (err instanceof FileUploadError) return mcpError(err.message);
const msg = err instanceof Error ? err.message : String(err);
return mcpError(`Ошибка загрузки файла: ${msg}`);
}
}
);
return server;
}