@retensy/mcp 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mjs CHANGED
@@ -15,14 +15,20 @@
15
15
  *
16
16
  * ENV:
17
17
  * RETENSY_MCP_TOKEN, RETENSY_BASE_URL, RETENSY_SESSION_COOKIE, RETENSY_COOKIE
18
+ * RETENSY_MCP_TELEMETRY=off|on|full — отчёты о неудачах (по умолчанию on, без значений аргументов)
19
+ * RETENSY_MCP_REPORT_URL — свой webhook вместо нашего
20
+ * RETENSY_MCP_AUTOUPDATE=0 — не обновлять пакет автоматически
18
21
  */
19
22
 
20
23
  import { createInterface } from "node:readline";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawn } from "node:child_process";
21
26
  import os from "node:os";
22
27
  import fs from "node:fs";
23
28
  import path from "node:path";
24
29
 
25
- const VERSION = "0.11.0";
30
+ const VERSION = "0.12.0";
31
+ const PKG_NAME = "@retensy/mcp";
26
32
  const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
27
33
  const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
28
34
  const TOKEN_FILE = path.join(CONFIG_DIR, "token");
@@ -49,6 +55,177 @@ function saveToken(token) {
49
55
  }
50
56
  function isAuthed() { return !!(getToken() || getCookie()); }
51
57
 
58
+ // ============================================================================
59
+ // Отчёты о неудачах + проверка обновлений
60
+ // ============================================================================
61
+ // ЗАЧЕМ: если клиент пытается сделать что-то, чего сервер не умеет (неизвестный
62
+ // инструмент, отказ публикации, ошибка API) — мы хотим об этом узнать и добавить
63
+ // поддержку. Отчёт уходит на webhook АНОНИМНО и БЕЗ СЕКРЕТОВ.
64
+ //
65
+ // Что уходит: имя инструмента, категория неудачи, текст ошибки, КЛЮЧИ аргументов
66
+ // (значения — только для безопасного списка полей вроде graphId/botId/kind),
67
+ // версия, платформа и анонимный id установки (хэш, не имя машины).
68
+ // Что НЕ уходит НИКОГДА: токены, cookie, пароли, креды интеграций, тела графов.
69
+ //
70
+ // Полностью выключить: RETENSY_MCP_TELEMETRY=off
71
+ // Присылать и значения аргументов (для отладки своей же установки): =full
72
+ //
73
+ // Отчёт уходит на НАШ эндпоинт `/api/mcp/report`, а не напрямую в мессенджер: адрес приёмника
74
+ // не должен лежать в публичном npm-пакете (оттуда его вытащил бы любой, а у вебхуков нет ни
75
+ // авторизации, ни лимита частоты). Сервер сам решает, куда переслать, и ограничивает частоту.
76
+ const REPORT_URL = (process.env.RETENSY_MCP_REPORT_URL || `${BASE}/api/mcp/report`).trim();
77
+ const REPORT_MODE = (process.env.RETENSY_MCP_TELEMETRY || "on").trim().toLowerCase();
78
+ /** Ключи, значения которых не отправляем ни в каком режиме. */
79
+ const SECRET_KEY_RX = /token|secret|cookie|passw|apikey|api_key|auth|cred/i;
80
+ /** Ключи, значения которых безопасны и реально нужны для разбора. */
81
+ const SAFE_ARG_KEYS = new Set(["graphId", "botId", "targetBotId", "templateId", "kind", "value",
82
+ "name", "slug", "id", "page", "size", "preview", "backup", "summary", "publish", "dryRun", "query"]);
83
+ const REPORT_MAX = 20; // на процесс: цикл ретраев не должен залить вебхук
84
+ let reportCount = 0;
85
+ const reportSeen = new Set(); // дедуп одинаковых неудач в рамках процесса
86
+
87
+ /** Стабильный анонимный id установки (FNV-1a) — группировать отчёты одного пользователя без PII. */
88
+ function installId() {
89
+ let h = 0x811c9dc5;
90
+ for (const ch of `${os.hostname()}|${os.homedir()}`) { h ^= ch.charCodeAt(0); h = (h * 0x01000193) >>> 0; }
91
+ return h.toString(16).padStart(8, "0");
92
+ }
93
+
94
+ function safeArgs(toolName, a) {
95
+ // set_token несёт секрет целиком — не сериализуем его вообще.
96
+ if (toolName === "set_token") return '{"token":"<скрыт>"}';
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(a || {})) {
99
+ if (SECRET_KEY_RX.test(k)) { out[k] = "<скрыт>"; continue; }
100
+ const show = REPORT_MODE === "full" || SAFE_ARG_KEYS.has(k);
101
+ if (Array.isArray(v)) out[k] = `<array:${v.length}>`;
102
+ else if (v && typeof v === "object") out[k] = `<object:${Object.keys(v).length}>`;
103
+ else if (show) out[k] = typeof v === "string" ? v.slice(0, 120) : v;
104
+ else out[k] = `<${typeof v}>`;
105
+ }
106
+ return JSON.stringify(out).slice(0, 900); // сервер обрежет ещё раз, но зря тащить не будем
107
+ }
108
+
109
+ function failureCategory(msg) {
110
+ const m = String(msg || "");
111
+ if (/^Неизвестный инструмент/.test(m)) return "unknown_tool";
112
+ // Токен вообще не настроен — это обычное состояние нового пользователя, а не пробел
113
+ // в возможностях: такие отчёты не отправляем (иначе каждый новичок зашумит канал).
114
+ if (/Нет доступа к retensy/.test(m)) return "not_configured";
115
+ if (/Доступ отклонён/.test(m)) return "auth_rejected"; // токен есть, но отвергнут — это стоит знать
116
+ const http = m.match(/HTTP (\d{3})/);
117
+ if (http) return `http_${http[1]}`;
118
+ return "error";
119
+ }
120
+
121
+ /** Fire-and-forget: никогда не задерживает и не ломает ответ инструмента. */
122
+ function reportFailure({ tool, args, message, category }) {
123
+ if (REPORT_MODE === "off" || !REPORT_URL || reportCount >= REPORT_MAX) return;
124
+ const cat = category || failureCategory(message);
125
+ if (cat === "not_configured") return;
126
+ const key = `${tool}|${cat}|${String(message || "").slice(0, 120)}`;
127
+ if (reportSeen.has(key)) return;
128
+ reportSeen.add(key);
129
+ reportCount += 1;
130
+ const body = {
131
+ tool: String(tool || "?").slice(0, 200),
132
+ category: cat,
133
+ message: String(message || "").slice(0, 1500),
134
+ args: safeArgs(tool, args),
135
+ mcpVersion: VERSION,
136
+ node: process.version,
137
+ platform: process.platform,
138
+ baseUrl: BASE,
139
+ installId: installId(),
140
+ };
141
+ const headers = { "Content-Type": "application/json" };
142
+ // Токен прикладываем ТОЛЬКО когда отчёт идёт на наш же адрес — тогда сервер покажет,
143
+ // кому именно не хватило возможности. На сторонний RETENSY_MCP_REPORT_URL токен не уходит.
144
+ if (REPORT_URL.startsWith(`${BASE}/`)) {
145
+ const token = getToken();
146
+ if (token) headers.Authorization = `Bearer ${token}`;
147
+ }
148
+ try {
149
+ const ac = new AbortController();
150
+ const timer = setTimeout(() => ac.abort(), 4000);
151
+ fetch(REPORT_URL, { method: "POST", headers, body: JSON.stringify(body), signal: ac.signal })
152
+ .catch(() => {}).finally(() => clearTimeout(timer));
153
+ } catch { /* телеметрия не имеет права влиять на работу */ }
154
+ }
155
+
156
+ // ---- Проверка обновлений ----
157
+ const UPDATE_FILE = path.join(CONFIG_DIR, "update-check.json");
158
+ const UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
159
+ const AUTOUPDATE = (process.env.RETENSY_MCP_AUTOUPDATE || "1").trim() !== "0";
160
+ // Запущены из node_modules/_npx → пакет обновляется npm. Запущены из git-чекаута
161
+ // (плагин Claude Code) → npm бесполезен: исполняется файл репозитория, а не пакет.
162
+ const SELF_DIR = (() => { try { return path.dirname(fileURLToPath(import.meta.url)); } catch { return ""; } })();
163
+ const IS_NPM_INSTALL = /[\\/](node_modules|_npx)[\\/]/.test(SELF_DIR);
164
+ let updateNotice = "";
165
+ let noticeDelivered = false;
166
+
167
+ function cmpVersions(a, b) {
168
+ const pa = String(a).split("."), pb = String(b).split(".");
169
+ for (let i = 0; i < 3; i += 1) {
170
+ const x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
171
+ if (x !== y) return x > y ? 1 : -1;
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ function spawnSelfUpdate() {
177
+ try {
178
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
179
+ // stdio:"ignore" обязателен: любой вывод в stdout сломал бы JSON-RPC.
180
+ const child = spawn(npm, ["i", "-g", `${PKG_NAME}@latest`],
181
+ { detached: true, stdio: "ignore", shell: process.platform === "win32" });
182
+ child.unref();
183
+ return true;
184
+ } catch { return false; }
185
+ }
186
+
187
+ function applyUpdateNotice(latest) {
188
+ updateNotice = `⬆️ Доступна новая версия retensy-mcp: ${VERSION} → ${latest}. `;
189
+ if (IS_NPM_INSTALL) {
190
+ // Процесс НЕ МОЖЕТ подменить свой уже загруженный код — обновление вступит в силу
191
+ // только после перезапуска MCP-сервера. Честно об этом пишем.
192
+ updateNotice += AUTOUPDATE && spawnSelfUpdate()
193
+ ? "Обновление запущено в фоне (npm i -g), применится ПОСЛЕ перезапуска MCP-сервера."
194
+ : `Обнови вручную: npm i -g ${PKG_NAME}@latest, затем перезапусти MCP-сервер.`;
195
+ } else {
196
+ updateNotice += "Сервер запущен из репозитория/плагина — обнови плагин (git pull) и перезапусти MCP-сервер.";
197
+ }
198
+ process.stderr.write(`[retensy-mcp] ${updateNotice}\n`);
199
+ }
200
+
201
+ /** Тихо: нет сети или реестр недоступен — работа не должна ломаться. */
202
+ async function checkForUpdate() {
203
+ try {
204
+ const cached = JSON.parse(fs.readFileSync(UPDATE_FILE, "utf8"));
205
+ if (cached && Date.now() - cached.at < UPDATE_TTL_MS) {
206
+ if (cached.latest && cmpVersions(cached.latest, VERSION) > 0) applyUpdateNotice(cached.latest);
207
+ return;
208
+ }
209
+ } catch { /* кэша нет или он битый — проверяем в реестре */ }
210
+ let latest = "";
211
+ try {
212
+ const ac = new AbortController();
213
+ const timer = setTimeout(() => ac.abort(), 4000);
214
+ // Сокращённый packument (~700 байт) + dist-tags.latest. ВАЖНО: на эндпоинте
215
+ // /<pkg>/latest этот accept даёт HTTP 406 — заголовок работает только на packument.
216
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME.replace("/", "%2f")}`,
217
+ { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: ac.signal });
218
+ clearTimeout(timer);
219
+ if (res.ok) latest = (await res.json())?.["dist-tags"]?.latest || "";
220
+ } catch { return; }
221
+ if (!latest) return;
222
+ try {
223
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
224
+ fs.writeFileSync(UPDATE_FILE, JSON.stringify({ at: Date.now(), latest }));
225
+ } catch { /* не смогли записать кэш — не страшно */ }
226
+ if (cmpVersions(latest, VERSION) > 0) applyUpdateNotice(latest);
227
+ }
228
+
52
229
  const NO_AUTH_HELP =
53
230
  "Нет доступа к retensy /bots — не настроена авторизация.\n\n" +
54
231
  `Как подключить (помоги пользователю по шагам):\n` +
@@ -66,6 +243,23 @@ function authHeaders() {
66
243
  return h;
67
244
  }
68
245
 
246
+ // Ошибка не-2xx ответа — один форматтер на все вызовы API. 422 с errors[] — отказ проверок (publish, а с
247
+ // аудита H2 и PUT активного графа). Агенту нужны ВСЕ причины: раньше здесь был JSON, обрезанный до 600
248
+ // символов, и хвост ошибок терялся. Прочее тело — как есть; пустое (так отвечают 409 и многие 400) —
249
+ // только статус, без «null» вместо причины.
250
+ function httpError(method, path_, status, data) {
251
+ const errors = status === 422 && Array.isArray(data?.errors) ? data.errors : null;
252
+ const raw = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data);
253
+ const msg = errors
254
+ ? `отклонено проверками (ошибок: ${errors.length}):\n` +
255
+ errors.map((e) => `${e?.code || "?"}${e?.nodeId ? `@${e.nodeId}` : ""}: ${e?.message || ""}`).join("\n")
256
+ : raw.slice(0, 600);
257
+ const err = new Error(`${method} ${path_} → HTTP ${status}.${msg ? ` ${msg}` : ""}`);
258
+ err.status = status; // структурный разбор — арка E (fe#28)
259
+ err.data = data;
260
+ return err;
261
+ }
262
+
69
263
  async function api(path_, { method = "GET", body } = {}) {
70
264
  if (!isAuthed()) throw new Error(NO_AUTH_HELP);
71
265
  const res = await fetch(`${BASE}${path_}`, {
@@ -81,8 +275,7 @@ async function api(path_, { method = "GET", body } = {}) {
81
275
  throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден, отозван или истёк.\n` +
82
276
  `Создай новый на ${TOKENS_PAGE} и пришли мне — я сохраню через set_token.`);
83
277
  }
84
- const msg = typeof data === "string" ? data : JSON.stringify(data);
85
- throw new Error(`${method} ${path_} → HTTP ${res.status}. ${(msg || "").slice(0, 600)}`);
278
+ throw httpError(method, path_, res.status, data);
86
279
  }
87
280
  return data;
88
281
  }
@@ -132,8 +325,7 @@ async function uploadMedia({ filePath, url, filename }) {
132
325
  if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
133
326
  if (res.status === 402) throw new Error("Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф на /bots/subscription.");
134
327
  if (res.status === 413) throw new Error("Файл больше 50 МБ (HTTP 413) — лимит Telegram для видео/документов.");
135
- const msg = typeof data === "string" ? data : JSON.stringify(data);
136
- throw new Error(`POST /api/bots/media → HTTP ${res.status}. ${(msg || "").slice(0, 600)}`);
328
+ throw httpError("POST", "/api/bots/media", res.status, data);
137
329
  }
138
330
  return data;
139
331
  }
@@ -179,21 +371,22 @@ const TOOLS = [
179
371
  { name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
180
372
  { name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
181
373
  { name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
374
+ { name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Read-only.", inputSchema: { type: "object", properties: {} } },
182
375
  { name: "get_graph", description: "Получить граф по graphId. Для БОЛЬШИХ графов (десятки узлов JSON может превысить лимит токенов) используй summary:true (компактная сводка: id/type/title/позиции + рёбра) или saveToFile (записать полный граф на диск и вернуть сводку+путь — потом правь файл и заливай через update_graph/edit_graph_live с graphFile).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, summary: { type: "boolean", description: "true = вернуть компактную сводку без объёмных text/cards/buttons" }, saveToFile: { type: "string", description: "Путь: записать полный граф (JSON) на диск, вернуть сводку + путь" } }, required: ["graphId"] } },
183
376
  { name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
184
- { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
185
- { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: PUT не валидирует — перед вызовом прогони offline validate.mjs и dry_run.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
186
- { name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу (читает активный граф заново из БД).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
377
+ { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
378
+ { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
379
+ { name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
187
380
  { name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
188
- { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
381
+ { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
189
382
  { name: "import_funnel", description: "Всё за раз: создать граф, залить узлы/рёбра, (опц.) dry-run /start, опубликовать. Граф можно передать инлайном (graph) или файлом (graphFile).", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, graph: { type: "object" }, graphFile: { type: "string", description: "Путь к локальному JSON графа вместо инлайн graph; поддерживается ~" }, dryRun: { type: "boolean" }, publish: { type: "boolean" } }, required: ["botId"] } },
190
383
  { name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
191
384
  { name: "create_graph_from_template", description: "Создать граф (DRAFT) из шаблона (см. list_templates). Возвращает граф с id — дальше правь через update_graph.", inputSchema: { type: "object", properties: { botId: { type: "string" }, templateId: { type: "string" }, name: { type: "string" } }, required: ["botId", "templateId"] } },
192
385
  { name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
193
- { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
194
- { name: "copy_graph", description: "Скопировать граф в ДРУГОГО бота (в т.ч. на другую платформу). Возвращает {graphId, sourcePlatform, targetPlatform, notes[]}. notes[] помечают, что адаптировано (severity=TRANSFORM, напр. вопрос-контакт → ввод телефона текстом), что требует ручной правки (MANUAL, напр. условие SUBSCRIBED в MAX) и особенности платформы (INFO). Авто-адаптация узлов реализована для Telegram⇄MAX; при копировании в/из Instagram-бота граф копируется без трансформаций — несовместимые узлы будут отмечены при публикации (IG-allowlist). preview=true — только проверка совместимости, без копирования. Тот же бот запрещён (для дублирования есть clone_graph).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, targetBotId: { type: "string", description: "id бота-получателя (см. list_bots)" }, preview: { type: "boolean", description: "true = только отчёт о совместимости, ничего не сохраняется" } }, required: ["graphId", "targetBotId"] } },
386
+ { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного. Клон сценария-вебхука получает собственный путь и секрет вебхука.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
387
+ { name: "copy_graph", description: "Скопировать граф в ДРУГОГО бота (в т.ч. на другую платформу). Возвращает {graphId, sourcePlatform, targetPlatform, notes[]}. notes[] помечают, что адаптировано (severity=TRANSFORM, напр. вопрос-контакт → ввод телефона текстом), что требует ручной правки (MANUAL, напр. условие SUBSCRIBED в MAX) и особенности платформы (INFO). Авто-адаптация узлов реализована для Telegram⇄MAX; при копировании в/из Instagram-бота граф копируется без трансформаций — несовместимые узлы будут отмечены при публикации (IG-allowlist). preview=true — только проверка совместимости, без копирования. Тот же бот запрещён (для дублирования есть clone_graph). Копия сценария-вебхука становится обычным сценарием бота-получателя (вход по вебхуку не переносится).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, targetBotId: { type: "string", description: "id бота-получателя (см. list_bots)" }, preview: { type: "boolean", description: "true = только отчёт о совместимости, ничего не сохраняется" } }, required: ["graphId", "targetBotId"] } },
195
388
  { name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
196
- { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
389
+ { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации). HTTP 409 — граф не опубликован или это сценарий-вебхук (источник WEBHOOK — такой включается своей публикацией в вебе).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
197
390
  { name: "upload_file", description: "Загрузить файл в библиотеку /bots/files (POST /api/bots/media) и получить публичный URL для вставки в сценарий. Передай path (локальный файл) ИЛИ url (перезалить файл по ссылке в своё хранилище). Возвращает {id, url, mediaType, sizeBytes, originalName}. Полученный url ставь в медиа-карточку SEND_MESSAGE (image/video/audio/file/voice/videonote → поле url; gallery → urls[]) или в SEND_PHOTO.photoUrl. Лимит 50 МБ; типы: image/video/audio/pdf/zip/doc(x)/xlsx/pptx/txt (SVG запрещён); при нехватке места — HTTP 402.", inputSchema: { type: "object", properties: { path: { type: "string", description: "Путь к локальному файлу (поддерживается ~)" }, url: { type: "string", description: "Ссылка на файл — будет скачан и перезалит в /bots/files" }, filename: { type: "string", description: "Переопределить имя файла (необязательно)" } } } },
198
391
  { name: "list_files", description: "Список файлов в библиотеке /bots/files (GET /api/bots/media) + использовано/лимит байт. Бери готовые url отсюда, чтобы не загружать одно и то же повторно.", inputSchema: { type: "object", properties: {} } },
199
392
  { name: "delete_file", description: "Удалить файл из библиотеки /bots/files по id (DELETE /api/bots/media/{id}). Освобождает место в хранилище тарифа.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
@@ -210,12 +403,13 @@ async function handleCall(params) {
210
403
  const a = (params && params.arguments) || {};
211
404
  switch (params && params.name) {
212
405
  case "setup": {
406
+ const upd = updateNotice ? `\n\n${updateNotice}` : "";
213
407
  if (isAuthed()) {
214
408
  const via = getToken() ? "персональный токен" : "session-cookie";
215
- return okResult(`✅ Авторизация настроена (${via}). База API: ${BASE}.\n` +
216
- `Можно собирать и публиковать ботов: list_bots, create_graph, import_funnel и др.`);
409
+ return okResult(`✅ Авторизация настроена (${via}). База API: ${BASE}. Версия MCP: ${VERSION}.\n` +
410
+ `Можно собирать и публиковать ботов: list_bots, create_graph, import_funnel и др.${upd}`);
217
411
  }
218
- return okResult(NO_AUTH_HELP);
412
+ return okResult(NO_AUTH_HELP + upd);
219
413
  }
220
414
  case "set_token": {
221
415
  const t = (a.token || "").trim();
@@ -235,6 +429,7 @@ async function handleCall(params) {
235
429
  case "list_bots": return okResult(await api("/api/bots"));
236
430
  case "list_graphs": return okResult(await api(`/api/bots/${a.botId}/graphs`));
237
431
  case "list_channels": return okResult(await api(`/api/bots/${a.botId}/linked-chats`));
432
+ case "list_integrations": return okResult(await api("/api/bots/integrations"));
238
433
  case "get_graph": {
239
434
  const g = await api(`/api/bots/graphs/${a.graphId}`);
240
435
  if (a.saveToFile) {
@@ -275,7 +470,11 @@ async function handleCall(params) {
275
470
  const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
276
471
  if (a.name ?? src.name) payload.name = a.name ?? src.name;
277
472
  const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
278
- steps.push(`правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`);
473
+ // До бота доходит только правка опубликованного графа: черновик после publish_graph остаётся DRAFT, живой —
474
+ // его копия (publishedGraphId). Иначе агент решит, что поправил бота, а правка легла в черновик.
475
+ steps.push(saved?.status === "PUBLISHED"
476
+ ? `правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`
477
+ : `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs; после publish_graph черновика — publishedGraphId)`);
279
478
  return okResult({ graphId: a.graphId, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null, edges: Array.isArray(saved?.edges) ? saved.edges.length : null, steps });
280
479
  }
281
480
  case "patch_graph": {
@@ -315,10 +514,27 @@ async function handleCall(params) {
315
514
  const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
316
515
  return okResult({ graphId: a.graphId, changed: true, totalMatches: total, replacements: report, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null });
317
516
  }
318
- case "dry_run":
319
- return okResult(await api(`/api/bots/graphs/${a.graphId}/dry-run`, { method: "POST", body: { kind: a.kind, value: a.value, fromUsername: a.fromUsername, presetVariables: a.presetVariables, presetTags: a.presetTags } }));
320
- case "publish_graph":
321
- return okResult(await api(`/api/bots/graphs/${a.graphId}/publish`, { method: "POST" }));
517
+ case "dry_run": {
518
+ // В конфиге узла команда хранится БЕЗ слэша ({command:"start"}), а рантайм матчит
519
+ // текст сообщения — со слэшем. Без нормализации dry_run("start") молча даёт NO_MATCH,
520
+ // хотя сценарий рабочий.
521
+ const value = a.kind === "command" && typeof a.value === "string" && !a.value.startsWith("/")
522
+ ? `/${a.value}`
523
+ : a.value;
524
+ return okResult(await api(`/api/bots/graphs/${a.graphId}/dry-run`, { method: "POST", body: { kind: a.kind, value, fromUsername: a.fromUsername, presetVariables: a.presetVariables, presetTags: a.presetTags } }));
525
+ }
526
+ case "publish_graph": {
527
+ const pub = await api(`/api/bots/graphs/${a.graphId}/publish`, { method: "POST" });
528
+ // errors[] приходит с HTTP 200, но для пользователя это «не получилось» — и самый
529
+ // ценный сигнал: видно, какого узла/возможности ему не хватило.
530
+ if (Array.isArray(pub?.errors) && pub.errors.length) {
531
+ reportFailure({
532
+ tool: "publish_graph", args: a, category: "publish_rejected",
533
+ message: pub.errors.map((e) => `${e.code || "?"}${e.nodeId ? `@${e.nodeId}` : ""}: ${e.message || ""}`).join("\n"),
534
+ });
535
+ }
536
+ return okResult(pub);
537
+ }
322
538
  case "import_funnel": {
323
539
  const src = a.graphFile ? extractGraph(readGraphFile(a.graphFile)) : extractGraph(a.graph);
324
540
  const steps = [];
@@ -335,6 +551,10 @@ async function handleCall(params) {
335
551
  const pub = await api(`/api/bots/graphs/${graphId}/publish`, { method: "POST" });
336
552
  if (pub.errors && pub.errors.length) {
337
553
  steps.push(`❌ публикация не прошла, ошибок: ${pub.errors.length}`);
554
+ reportFailure({
555
+ tool: "import_funnel", args: a, category: "publish_rejected",
556
+ message: pub.errors.map((e) => `${e.code || "?"}${e.nodeId ? `@${e.nodeId}` : ""}: ${e.message || ""}`).join("\n"),
557
+ });
338
558
  return okResult({ graphId, steps, publishErrors: pub.errors });
339
559
  }
340
560
  steps.push(`✅ опубликовано: publishedGraphId=${pub.publishedGraphId}`);
@@ -409,7 +629,20 @@ rl.on("line", async (line) => {
409
629
  send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
410
630
  } else if (method === "tools/call") {
411
631
  let result;
412
- try { result = await handleCall(params); } catch (e) { result = errResult(e); }
632
+ try {
633
+ result = await handleCall(params);
634
+ } catch (e) {
635
+ result = errResult(e);
636
+ // Единая точка: сюда приходит ЛЮБАЯ неудача инструмента — в т.ч. «Неизвестный
637
+ // инструмент» (значит клиент хотел возможность, которой у нас нет).
638
+ reportFailure({ tool: params?.name || "?", args: params?.arguments, message: e?.message || String(e) });
639
+ }
640
+ // Уведомление о новой версии отдаём один раз за сессию, чтобы не шуметь в каждом ответе.
641
+ // setup печатает его сам — там не дублируем.
642
+ if (updateNotice && !noticeDelivered && params?.name !== "setup") {
643
+ noticeDelivered = true;
644
+ result = { ...result, content: [...(result.content || []), { type: "text", text: updateNotice }] };
645
+ }
413
646
  send({ jsonrpc: "2.0", id, result });
414
647
  } else if (method === "ping") {
415
648
  send({ jsonrpc: "2.0", id, result: {} });
@@ -421,4 +654,7 @@ rl.on("line", async (line) => {
421
654
  }
422
655
  });
423
656
 
424
- process.stderr.write(`[retensy-mcp] MCP ${VERSION}. BASE=${BASE}. Авторизация: ${getToken() ? "токен" : getCookie() ? "cookie" : "не задана (вызови setup)"}.\n`);
657
+ process.stderr.write(`[retensy-mcp] MCP ${VERSION}. BASE=${BASE}. Авторизация: ${getToken() ? "токен" : getCookie() ? "cookie" : "не задана (вызови setup)"}. Отчёты о неудачах: ${REPORT_MODE}.\n`);
658
+
659
+ // Проверка обновлений — не блокирует старт и не ломает работу без сети.
660
+ checkForUpdate();