Upload-тикеты: загрузка файлов через REST без знания API-ключа
- MCP create_upload_ticket → короткоживущий uploadUrl (10 мин, in-memory) - POST /api/upload/ticket/:token — тот же пайплайн (processUploadRequest вынесен из /api/upload), авторизация тикетом, проверки скоупов - get_api_guide: флоу без ключа — ticket → curl → привязка по fileUrl
This commit is contained in:
@@ -35,6 +35,7 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri
|
||||
import type { Request, Response } from "express";
|
||||
import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema";
|
||||
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
|
||||
import { createUploadTicket } from "./utils/upload-tickets";
|
||||
import beautify from "js-beautify";
|
||||
import {
|
||||
semanticSearch,
|
||||
@@ -165,6 +166,7 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
|
||||
'upload_message_file',
|
||||
'upload_directory_file',
|
||||
'upload_table_row_file',
|
||||
'create_upload_ticket',
|
||||
];
|
||||
|
||||
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
|
||||
@@ -5095,39 +5097,109 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
|
||||
}
|
||||
);
|
||||
|
||||
// create_upload_ticket — короткоживущий URL загрузки БЕЗ API-ключа.
|
||||
// Сценарий: агент видит MCP-инструменты, но значение ключа зашито в конфиге
|
||||
// его MCP-клиента — выполнить curl с X-Api-Key он не может. Тикет решает это.
|
||||
register(
|
||||
"create_upload_ticket",
|
||||
{
|
||||
title: "Create Upload Ticket",
|
||||
description:
|
||||
"Get a short-lived (10 min) upload URL so files can be uploaded via plain curl WITHOUT the API key " +
|
||||
"(the key value is hidden in your MCP client config and cannot be used in shell commands). " +
|
||||
"Upload the binary with `curl -F \"file=@...\" \"<uploadUrl>?taskId=...&fieldId=...\"` (no auth headers), " +
|
||||
"then attach the returned url via upload_task_file / upload_message_file / upload_directory_file / upload_table_row_file (fileUrl). " +
|
||||
"The ticket is reusable within its TTL and bound to your organization and key scopes.",
|
||||
inputSchema: {
|
||||
taskId: z.number().int().optional().describe("Optional task ID the file will be attached to — checked against key scopes and embedded into curlExample"),
|
||||
fieldId: z.number().int().optional().describe("Optional file field ID — embedded into curlExample"),
|
||||
},
|
||||
},
|
||||
async ({ taskId, fieldId }) => {
|
||||
if (scopes.mode === 'read') {
|
||||
return mcpError('Загрузка файлов недоступна в режиме только чтение (scopes.mode = read)');
|
||||
}
|
||||
if (taskId !== undefined) {
|
||||
const task = await storage.getTask(taskId, organizationId);
|
||||
if (!task) return mcpError(`Задача ${taskId} не найдена`);
|
||||
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
|
||||
}
|
||||
try {
|
||||
// Автор загрузки по тикету = владелец ключа (fallback: актор организации)
|
||||
let createdBy = apiKeyRecord?.createdBy;
|
||||
if (!createdBy) createdBy = (await getActor()).user.id;
|
||||
const ticket = createUploadTicket({
|
||||
organizationId,
|
||||
apiKeyId: apiKeyRecord?.id ?? null,
|
||||
botId: apiKeyRecord?.botId ?? null,
|
||||
createdBy,
|
||||
scopes,
|
||||
});
|
||||
const baseUrl = (process.env.PUBLIC_APP_URL || process.env.APP_URL || process.env.BASE_URL || 'https://iistwin.ru').replace(/\/+$/, '');
|
||||
const uploadUrl = `${baseUrl}/api/upload/ticket/${ticket.token}`;
|
||||
const query = [
|
||||
taskId !== undefined ? `taskId=${taskId}` : null,
|
||||
fieldId !== undefined ? `fieldId=${fieldId}` : null,
|
||||
].filter(Boolean).join('&');
|
||||
const curlExample = `curl -F "file=@<путь_к_файлу>" "${uploadUrl}${query ? `?${query}` : ''}"`;
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
uploadUrl,
|
||||
expiresAt: ticket.expiresAt.toISOString(),
|
||||
curlExample,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return mcpError(`Ошибка создания тикета загрузки: ${msg}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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.",
|
||||
description: "Returns a compact Russian-language guide for AI agents: how to upload files via create_upload_ticket (curl WITHOUT the API key) and create tasks/comments via REST, 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. Загрузка файла (ТОЛЬКО через REST, multipart)
|
||||
## 1. Загрузка файла через upload-тикет (БЕЗ API-ключа — основной способ)
|
||||
|
||||
Через MCP бинарные данные НЕ передаются. Сначала загрузи файл через REST тем же ключом:
|
||||
Значение ключа зашито в конфиге твоего MCP-клиента и тебе недоступно, поэтому
|
||||
для curl используй короткоживущий тикет загрузки:
|
||||
|
||||
**Шаг 1.** Вызови MCP-инструмент \`create_upload_ticket({ taskId?, fieldId? })\` →
|
||||
получишь \`uploadUrl\`, \`expiresAt\` (TTL 10 минут) и готовый \`curlExample\`.
|
||||
|
||||
**Шаг 2.** Загрузи файл БЕЗ заголовков авторизации:
|
||||
|
||||
\`\`\`bash
|
||||
curl -F "file=@/path/report.pdf" \\
|
||||
-H "X-Api-Key: $KEY" \\
|
||||
"https://iistwin.ru/api/upload?taskId=<taskId>&fieldId=<fieldId>"
|
||||
curl -F "file=@/path/report.pdf" "<uploadUrl>?taskId=<taskId>&fieldId=<fieldId>"
|
||||
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
|
||||
\`\`\`
|
||||
|
||||
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
|
||||
- Тикет многоразовый в пределах TTL (можно загрузить несколько файлов), привязан к твоей организации.
|
||||
- Лимиты (дефолты, переопределяются 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.
|
||||
- Из ответа возьми \`url\`, \`name\`, \`size\` — они нужны для привязки (п.2).
|
||||
|
||||
Если значение ключа тебе ИЗВЕСТНО, можно загружать напрямую:
|
||||
\`curl -F "file=@..." -H "X-Api-Key: $KEY" "https://iistwin.ru/api/upload?taskId=...&fieldId=..."\`.
|
||||
|
||||
## 2. Привязка файла к задаче/справочнику (MCP, по fileUrl)
|
||||
|
||||
После загрузки вызови нужный MCP-инструмент с \`fileUrl\` (и \`fileName\`, желательно \`fileSize\` из ответа upload):
|
||||
|
||||
Reference in New Issue
Block a user