Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)
- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status - sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user - authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api' - Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100 - MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
This commit is contained in:
494
server/mcp.ts
494
server/mcp.ts
@@ -33,8 +33,8 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri
|
||||
}
|
||||
}
|
||||
import type { Request, Response } from "express";
|
||||
import type { Task, ApiKeyScopes } from "@shared/schema";
|
||||
import { normalizeApiKeyScopes } from "./utils/api-key";
|
||||
import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema";
|
||||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||||
import beautify from "js-beautify";
|
||||
import {
|
||||
semanticSearch,
|
||||
@@ -147,6 +147,7 @@ const READ_TOOLS: readonly string[] = [
|
||||
'get_task_tree',
|
||||
'get_user_field_values',
|
||||
'dadata_suggest',
|
||||
'get_api_guide',
|
||||
];
|
||||
|
||||
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
|
||||
@@ -171,10 +172,12 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||
// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full.
|
||||
|
||||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа.
|
||||
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа
|
||||
// + полная запись ключа (botId, createdBy, label) для атрибуции изменений.
|
||||
export interface ResolvedApiKey {
|
||||
organizationId: number;
|
||||
scopes: ApiKeyScopes;
|
||||
key: OrganizationApiKey;
|
||||
}
|
||||
|
||||
// Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы
|
||||
@@ -199,7 +202,7 @@ async function resolveApiKey(req: Request): Promise<ResolvedApiKey | null> {
|
||||
}
|
||||
if (!apiKey.isActive) return null;
|
||||
storage.touchApiKey(apiKey.id).catch(() => {});
|
||||
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes) };
|
||||
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes), key: apiKey };
|
||||
}
|
||||
|
||||
function taskToJson(t: Task) {
|
||||
@@ -216,7 +219,7 @@ function taskToJson(t: Task) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer {
|
||||
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyRecord: OrganizationApiKey | null): McpServer {
|
||||
const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" });
|
||||
|
||||
// ── Фильтрация инструментов по режиму ключа (scopes.mode) ─────────────────
|
||||
@@ -239,12 +242,107 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}) as typeof server.registerTool;
|
||||
|
||||
// ── Объектный доступ по scopes.formIds ────────────────────────────────────
|
||||
const isFormAllowed = (formId: number) => scopes.formIds === null || scopes.formIds.includes(formId);
|
||||
const isFormAllowed = (formId: number) => isFormAllowedByScopes(scopes, formId);
|
||||
const formDenied = (formId: number) => ({
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// ── Актор изменений по API-ключу ───────────────────────────────────────────
|
||||
// user — владелец ключа (fallback: первый админ) для notNull FK-колонок;
|
||||
// bot — бот ключа (если привязан); displayName — имя для аудита
|
||||
// (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек).
|
||||
interface McpActor {
|
||||
user: User;
|
||||
bot: Bot | null;
|
||||
displayName: string;
|
||||
}
|
||||
let actorPromise: Promise<McpActor> | null = null;
|
||||
const getActor = (): Promise<McpActor> => {
|
||||
if (!actorPromise) {
|
||||
actorPromise = (async () => {
|
||||
let user: User | null | undefined = apiKeyRecord?.createdBy
|
||||
? await storage.getUser(apiKeyRecord.createdBy).catch(() => null)
|
||||
: null;
|
||||
if (!user || user.organizationId !== organizationId) {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
user = orgUsers.find((u) => u.appRole === 'admin') ?? orgUsers[0] ?? null;
|
||||
}
|
||||
if (!user) throw new Error('В организации нет пользователей');
|
||||
const bot = apiKeyRecord?.botId
|
||||
? await storage.getBot(apiKeyRecord.botId, organizationId).catch(() => null) ?? null
|
||||
: null;
|
||||
const displayName = bot?.name
|
||||
?? (apiKeyRecord ? `Ключ «${apiKeyRecord.label}»` : null)
|
||||
?? (`${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || 'MCP');
|
||||
return { user, bot, displayName };
|
||||
})();
|
||||
}
|
||||
return actorPromise;
|
||||
};
|
||||
|
||||
// Поля аудит-записи от актора: для бота changedBy=null и botId выставлен,
|
||||
// канал фиксируется в metadata.source='mcp'.
|
||||
const actorAudit = (actor: McpActor) => ({
|
||||
changedBy: actor.bot ? null : actor.user.id,
|
||||
changedByName: actor.displayName,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
|
||||
// Источник файла для upload-инструментов: base64 (загрузка в хранилище)
|
||||
// или fileUrl (уже загруженный файл — только привязка, без повторной загрузки).
|
||||
// Ровно один источник обязателен.
|
||||
const resolveUploadSource = async (args: {
|
||||
fileName?: string;
|
||||
contentBase64?: string;
|
||||
fileUrl?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
taskId?: number | null;
|
||||
fieldId?: number | null;
|
||||
}): Promise<
|
||||
| { error: string }
|
||||
| { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } }
|
||||
> => {
|
||||
const hasBase64 = !!args.contentBase64;
|
||||
const hasUrl = !!args.fileUrl;
|
||||
if (hasBase64 === hasUrl) {
|
||||
return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' };
|
||||
}
|
||||
const actor = await getActor();
|
||||
if (hasUrl) {
|
||||
const url = args.fileUrl!;
|
||||
if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) {
|
||||
return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' };
|
||||
}
|
||||
const name = args.fileName || url.split('/').pop() || 'file';
|
||||
return {
|
||||
actor,
|
||||
file: {
|
||||
key: url.replace(/^\/api\/files\/|^\/uploads\//, ''),
|
||||
url,
|
||||
name,
|
||||
size: args.fileSize ?? 0,
|
||||
mimeType: args.mimeType ?? 'application/octet-stream',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!args.fileName) {
|
||||
return { error: 'fileName обязателен при загрузке через contentBase64' };
|
||||
}
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: actor.user.id,
|
||||
fileName: args.fileName,
|
||||
contentBase64: args.contentBase64!,
|
||||
mimeType: args.mimeType,
|
||||
taskId: args.taskId,
|
||||
fieldId: args.fieldId,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
return { actor, file };
|
||||
};
|
||||
|
||||
// ── Объектный доступ по scopes.tableIds (справочники) ─────────────────────
|
||||
const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId);
|
||||
const tableDenied = (tableId: number) => ({
|
||||
@@ -494,11 +592,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}
|
||||
}
|
||||
|
||||
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 actor = await getActor();
|
||||
|
||||
let parsedDueDate: Date | null = null;
|
||||
if (due_date) {
|
||||
@@ -513,7 +607,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
title,
|
||||
formId: form_id,
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
assignedTo: assigned_to ?? null,
|
||||
currentStatusId: resolvedStatusId,
|
||||
description: description ?? null,
|
||||
@@ -523,14 +617,12 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
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 },
|
||||
...actorAudit(actor),
|
||||
metadata: { title: task.title, source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP create_task):", e); });
|
||||
|
||||
if (field_values && typeof field_values === "object") {
|
||||
@@ -593,6 +685,23 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
isCompleted: isFinalStatus,
|
||||
completedAt: isFinalStatus ? (task.completedAt ?? new Date()) : null,
|
||||
});
|
||||
|
||||
// Аудит смены статуса с актором ключа (как REST: action 'status.changed')
|
||||
const oldStatus = statuses.find((s) => s.id === task.currentStatusId);
|
||||
if (task.currentStatusId !== status_id) {
|
||||
const actor = await getActor();
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task_id,
|
||||
organizationId,
|
||||
action: 'status.changed',
|
||||
fieldName: 'Статус',
|
||||
oldValue: oldStatus?.name ?? String(task.currentStatusId),
|
||||
newValue: validStatus.name,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task_status):", e); });
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -655,6 +764,49 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
}
|
||||
|
||||
const updated = await storage.updateTask(task_id, organizationId, updates);
|
||||
|
||||
// Аудит изменённых полей с актором ключа (как REST PUT /api/tasks/:id)
|
||||
{
|
||||
const actor = await getActor();
|
||||
const resolveUserName = async (userId: number | null | undefined): Promise<string | null> => {
|
||||
if (!userId) return null;
|
||||
const u = await storage.getUser(userId).catch(() => null);
|
||||
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(userId);
|
||||
};
|
||||
const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [
|
||||
{ key: 'title', label: 'Заголовок' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'assignedTo', label: 'Исполнитель' },
|
||||
{ key: 'dueDate', label: 'Срок' },
|
||||
];
|
||||
for (const { key, label } of trackedFields) {
|
||||
if (!(key in updates)) continue;
|
||||
const oldVal = task[key];
|
||||
const newVal = updates[key];
|
||||
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
|
||||
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
|
||||
if (oldStr === newStr) continue;
|
||||
let auditOldValue: string | null = oldVal === null || oldVal === undefined ? null : String(oldVal);
|
||||
let auditNewValue: string | null = newVal === null || newVal === undefined ? null : String(newVal);
|
||||
if (key === 'assignedTo') {
|
||||
[auditOldValue, auditNewValue] = await Promise.all([
|
||||
resolveUserName(oldVal as number | null),
|
||||
resolveUserName(newVal as number | null),
|
||||
]);
|
||||
}
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task_id,
|
||||
organizationId,
|
||||
action: 'task.updated',
|
||||
fieldName: label,
|
||||
oldValue: auditOldValue,
|
||||
newValue: auditNewValue,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task):", e); });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -823,17 +975,13 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
},
|
||||
},
|
||||
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 actor = await getActor();
|
||||
|
||||
const form = await storage.createForm({
|
||||
organizationId,
|
||||
name,
|
||||
description: description ?? null,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
isActive: true,
|
||||
chatEnabled: true,
|
||||
chatLayout: "default",
|
||||
@@ -1103,11 +1251,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
|
||||
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 createdBy = (await getActor()).user.id;
|
||||
const mod = await storage.createCustomTabModule({
|
||||
type,
|
||||
label,
|
||||
@@ -1179,11 +1323,7 @@ RULES for tab component code:
|
||||
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 createdBy = (await getActor()).user.id;
|
||||
const mod = await storage.createCustomTabModule({
|
||||
type,
|
||||
label,
|
||||
@@ -1560,11 +1700,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules:
|
||||
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 createdBy = (await getActor()).user.id;
|
||||
const createWarnings = validatePageCode(code);
|
||||
const formattedCode = formatPageCode(code);
|
||||
|
||||
@@ -2112,10 +2248,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
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 createdBy = (await getActor()).user.id;
|
||||
const item = await storage.createAutomation({
|
||||
organizationId,
|
||||
name,
|
||||
@@ -2596,11 +2729,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
};
|
||||
}
|
||||
|
||||
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 actor = await getActor();
|
||||
|
||||
const field = await storage.createFieldTemplate({
|
||||
code,
|
||||
@@ -2613,7 +2742,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
defaultValue: default_value ?? null,
|
||||
validationRules: null,
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2826,24 +2955,16 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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 actor = await getActor();
|
||||
|
||||
const row = await storage.createRegularTableRow({
|
||||
taskId: task_id,
|
||||
tabId: resolvedTabId,
|
||||
data: data ?? {},
|
||||
createdBy,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -2852,8 +2973,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: `Таблица «${tab?.name || resolvedTabId}»: добавлена строка`,
|
||||
oldValue: null,
|
||||
newValue: persistedData,
|
||||
changedBy: createdBy,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).catch((e: unknown) => { console.error("Audit log error (MCP append_table_row):", e); });
|
||||
|
||||
return {
|
||||
@@ -3320,18 +3441,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
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 actor = await getActor();
|
||||
|
||||
const table = await storage.createDataTable({
|
||||
name,
|
||||
description: description ?? null,
|
||||
columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })),
|
||||
organizationId,
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
@@ -3640,14 +3757,13 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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("В организации нет пользователей для авторства сообщения");
|
||||
const actor = await getActor();
|
||||
|
||||
try {
|
||||
const created = await sendTaskMessage({
|
||||
task,
|
||||
user: adminUser,
|
||||
user: actor.bot ? undefined : actor.user,
|
||||
botId: actor.bot?.id ?? null,
|
||||
organizationId,
|
||||
message: content,
|
||||
});
|
||||
@@ -3737,7 +3853,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
const actor = await getActor();
|
||||
|
||||
// Полная замена списка: удаляем лишних, добавляем недостающих
|
||||
const current = await storage.getTaskAssignees(taskId, organizationId);
|
||||
@@ -3755,11 +3871,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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 autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||||
|
||||
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);
|
||||
@@ -3773,15 +3886,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: 'Ответственные обновлены',
|
||||
oldValue: null,
|
||||
newValue: names || '(пусто)',
|
||||
changedBy: adminUser?.id ?? null,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).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)
|
||||
notifyTaskAssigned(refreshedTask, newAssignedTo, actor.user.id, organizationId)
|
||||
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
@@ -3868,9 +3981,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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("В организации нет пользователей для генерации документа");
|
||||
const actor = await getActor();
|
||||
|
||||
// Сервис генерации собирается так же, как в server/documents/routes.ts
|
||||
const templateService = new DocumentTemplateService();
|
||||
@@ -3883,7 +3994,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
templateId,
|
||||
taskId,
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
userId: actor.user.id,
|
||||
outputFormat: format,
|
||||
});
|
||||
return {
|
||||
@@ -4094,13 +4205,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
if (!recipient) {
|
||||
return mcpError(`Пользователь ${userId} не принадлежит организации`);
|
||||
}
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
const actor = await getActor();
|
||||
|
||||
const reminder = await storage.createTaskReminder({
|
||||
taskId,
|
||||
organizationId,
|
||||
createdByUserId: adminUser.id,
|
||||
createdByUserId: actor.user.id,
|
||||
remindAt: remindAtDate,
|
||||
note: message ?? null,
|
||||
recipients: [{ type: "user", userId }],
|
||||
@@ -4740,16 +4850,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
},
|
||||
},
|
||||
async ({ fileName, contentBase64, mimeType }) => {
|
||||
const orgUsers = await storage.getUsersByOrganization(organizationId);
|
||||
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
|
||||
if (!adminUser) return mcpError("В организации нет пользователей");
|
||||
const actor = await getActor();
|
||||
try {
|
||||
const file = await uploadFileFromBase64({
|
||||
organizationId,
|
||||
userId: adminUser.id,
|
||||
userId: actor.user.id,
|
||||
fileName,
|
||||
contentBase64,
|
||||
mimeType,
|
||||
botId: actor.bot?.id ?? null,
|
||||
});
|
||||
return {
|
||||
content: [{
|
||||
@@ -4771,18 +4880,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Task Field File",
|
||||
description:
|
||||
"Upload a file (base64) and APPEND it to a file-type form field of a task. " +
|
||||
"Upload a file and APPEND it to a file-type form field of a task. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " +
|
||||
"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)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64; for fileUrl defaults to the URL basename)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => {
|
||||
async ({ taskId, fieldId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
@@ -4794,20 +4906,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId, fieldId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
|
||||
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
|
||||
@@ -4832,7 +4934,6 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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,
|
||||
@@ -4841,12 +4942,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
fieldName: field.name,
|
||||
oldValue: existingValue?.value ?? null,
|
||||
newValue: newFiles,
|
||||
changedBy: adminUser.id,
|
||||
changedByName: editorName,
|
||||
...actorAudit(actor),
|
||||
metadata: { source: 'mcp' },
|
||||
}).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 });
|
||||
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
|
||||
indexTaskAsync(taskId, organizationId).catch(() => {});
|
||||
const freshTask = await storage.getTask(taskId, organizationId);
|
||||
eventBus.publishEvent({
|
||||
@@ -4879,39 +4980,34 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Message Attachment",
|
||||
description:
|
||||
"Upload a file (base64) and post it as a task comment attachment. " +
|
||||
"Upload a file and post it as a task comment attachment. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"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)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
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 }) => {
|
||||
async ({ taskId, fileName, contentBase64, fileUrl, fileSize, 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 source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
const created = await sendTaskMessage({
|
||||
task,
|
||||
user: adminUser,
|
||||
user: actor.bot ? undefined : actor.user,
|
||||
botId: actor.bot?.id ?? null,
|
||||
organizationId,
|
||||
message: content?.trim() || `📎 ${file.name}`,
|
||||
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
|
||||
@@ -4942,18 +5038,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
title: "Upload Directory Cell File",
|
||||
description:
|
||||
"Upload a file (base64) and write a link into a directory row cell. " +
|
||||
"Upload a file and write a link into a directory row cell. " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"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)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => {
|
||||
async ({ tableId, rowId, columnIndex, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
if (!isTableAllowed(tableId)) return tableDenied(tableId);
|
||||
const table = await storage.getDataTable(tableId, organizationId);
|
||||
if (!table) return mcpError(`Справочник ${tableId} не найден`);
|
||||
@@ -4964,18 +5063,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { file } = source;
|
||||
|
||||
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
|
||||
const values = Array.isArray(row.values) ? [...row.values] : [];
|
||||
@@ -5007,7 +5098,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
{
|
||||
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). " +
|
||||
"Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
|
||||
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
|
||||
"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: {
|
||||
@@ -5015,12 +5107,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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)"),
|
||||
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
|
||||
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
|
||||
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
|
||||
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
|
||||
mimeType: z.string().optional().describe("MIME type (optional)"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => {
|
||||
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
@@ -5034,19 +5128,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
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,
|
||||
});
|
||||
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
|
||||
if ('error' in source) return mcpError(source.error);
|
||||
const { actor, file } = source;
|
||||
|
||||
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
|
||||
// пишем markdown-ссылку [имя](url), как и в справочниках
|
||||
@@ -5063,7 +5148,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
taskId,
|
||||
tabId,
|
||||
data: { [columnId]: cellValue },
|
||||
createdBy: adminUser.id,
|
||||
createdBy: actor.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5085,6 +5170,91 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
}
|
||||
);
|
||||
|
||||
// get_api_guide
|
||||
register(
|
||||
"get_api_guide",
|
||||
{
|
||||
title: "Get API Guide",
|
||||
description: "Returns a compact Russian-language guide for AI agents: how to upload files and create tasks/comments via REST with the same API key, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const guide = `
|
||||
# Работа с API iistwin по ключу (гайд для ИИ-агента)
|
||||
|
||||
Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`).
|
||||
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
|
||||
|
||||
## 1. Загрузка файла (multipart, НЕ base64)
|
||||
|
||||
\`\`\`bash
|
||||
curl -F "file=@/path/report.pdf" \\
|
||||
-H "X-Api-Key: $KEY" \\
|
||||
"https://iistwin.ru/api/upload?taskId=<taskId>&fieldId=<fieldId>"
|
||||
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
|
||||
\`\`\`
|
||||
|
||||
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
|
||||
- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ,
|
||||
документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ.
|
||||
- Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar.
|
||||
Для jpg/png/pdf проверяются magic bytes.
|
||||
- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 —
|
||||
грузи через REST /api/upload, а привязывай через fileUrl (п.2).
|
||||
|
||||
## 2. Привязка файла к задаче/справочнику
|
||||
|
||||
Проще всего — MCP-инструменты с fileUrl (без повторной загрузки):
|
||||
- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи.
|
||||
- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением.
|
||||
- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника.
|
||||
- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы.
|
||||
|
||||
Либо напрямую REST (file-поле — массив объектов {url, name, size}):
|
||||
\`\`\`bash
|
||||
# Прочитать текущее значение, ДОБАВИТЬ объект, записать назад:
|
||||
curl -X PATCH -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"value":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||||
"https://iistwin.ru/api/tasks/<taskId>/field-values/<fieldId>"
|
||||
\`\`\`
|
||||
ВНИМАНИЕ: PATCH полностью заменяет значение поля — сначала прочитай задачу
|
||||
(\`get_task\` в MCP или GET /api/tasks/<id> в REST) и добавь файл к существующему массиву.
|
||||
|
||||
## 3. Создание задачи и комментария через REST
|
||||
|
||||
\`\`\`bash
|
||||
# Задача (customFields: { "customField_<fieldId>": значение })
|
||||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"title":"Новая задача","customFields":{"customField_12":"Текст"}}' \\
|
||||
"https://iistwin.ru/api/forms/<formId>/tasks"
|
||||
|
||||
# Комментарий (с вложением — attachments: [{url, name, size, mimeType?}])
|
||||
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
|
||||
-d '{"message":"Готово","attachments":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
|
||||
"https://iistwin.ru/api/tasks/<taskId>/messages"
|
||||
\`\`\`
|
||||
|
||||
## 4. Права ключа
|
||||
|
||||
- mode=read: только чтение (запись → 403). mode=write: чтение+создание. mode=full: всё.
|
||||
- formIds/tableIds ограничивают список доступных форм/справочников (null = все).
|
||||
- REST по ключу открыт только для: POST /api/upload, POST /api/tasks/:id/messages,
|
||||
PATCH /api/tasks/:id/field-values/:fieldId, POST /api/tasks/:id/field-values,
|
||||
POST /api/forms/:id/tasks. Остальное — через MCP-инструменты.
|
||||
- Атрибуция: действия по ключу бота записываются в историю от имени бота (bot_id),
|
||||
по обычному ключу — «Ключ "label"» (metadata.source = 'mcp' | 'api').
|
||||
|
||||
## 5. Форматы значений
|
||||
|
||||
- file-поле задачи: массив \`[{url, name, size}]\` (множественное — добавляй, не заменяй).
|
||||
- Вложение сообщения: \`{url, name, size, mimeType?}\`.
|
||||
- Ячейка справочника/таб-таблицы: markdown-ссылка \`[имя](url)\`.
|
||||
- url файла: \`/api/files/<key>\` (S3) или \`/uploads/<key>\` (локальный режим).
|
||||
`.trim();
|
||||
return { content: [{ type: "text" as const, text: guide }] };
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -5114,7 +5284,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { organizationId, scopes } = resolved;
|
||||
const { organizationId, scopes, key } = resolved;
|
||||
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
|
||||
@@ -5126,7 +5296,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
|
||||
mcpTransports.set(sid, { transport, server, organizationId, scopes });
|
||||
},
|
||||
});
|
||||
const server = buildMcpServer(organizationId, scopes);
|
||||
const server = buildMcpServer(organizationId, scopes, key);
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -5184,7 +5354,7 @@ export async function handleMcpSse(req: Request, res: Response) {
|
||||
res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." });
|
||||
return;
|
||||
}
|
||||
const { organizationId, scopes } = resolved;
|
||||
const { organizationId, scopes, key } = resolved;
|
||||
|
||||
const transport = new SSEServerTransport("/mcp/messages", res);
|
||||
const sessionId = transport.sessionId;
|
||||
@@ -5195,7 +5365,7 @@ export async function handleMcpSse(req: Request, res: Response) {
|
||||
sseSessions.delete(sessionId);
|
||||
};
|
||||
|
||||
const server = buildMcpServer(organizationId, scopes);
|
||||
const server = buildMcpServer(organizationId, scopes, key);
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user