@retensy/mcp 0.42.0 → 0.44.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/README.md CHANGED
@@ -449,7 +449,9 @@ RETENSY_MCP_TOKEN=zmcp_... node src/index.mjs # стартует stdio MCP-с
449
449
  подтверждение проверяет сервер. Новые действия появляются **без релиза npm**: каталог обновляется раз в
450
450
  10 минут, клиент получает `notifications/tools/list_changed`. Каталог кэшируется в
451
451
  `~/.retensy-bot-graph/catalog-*.json`. Действия с подтверждением (удаление, рассылка всем, публикация)
452
- выполняются только с `confirm:true` после явного согласия пользователя. Выключить: `RETENSY_MCP_CATALOG=off`.
452
+ повторяются с `confirmToken` из ответа сервера (привязан к аргументам, живёт 5 минут) только после явного согласия пользователя. Каталог только добавляет новые имена и не перекрывает встроенные инструменты; для не-localhost `RETENSY_BASE_URL` нужен https. Выключить: `RETENSY_MCP_CATALOG=off`.
453
+
454
+ Настоящее согласие человека для деструктивных инструментов - это подтверждение вызова в самом MCP-клиенте (такие инструменты помечены `destructiveHint`): не включайте для них автоодобрение.
453
455
 
454
456
  ## Отчёты о неудачах (телеметрия)
455
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "MCP server to build and publish Telegram and MAX bot funnels/automations, broadcasts, service integrations, block-based websites (incl. free-layout Zero blocks, designs, templates, custom domains, form leads) and Markdown blog articles in the Retensy Bots service. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/catalog.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Единый каталог действий сервиса (GET /api/agent-tools/catalog) — тот же список, что у «Чата с ИИ» в кабинете.
2
2
  // Инструменты каталога регистрируются динамически и вызываются через POST /api/agent-tools/invoke: новые действия
3
3
  // появляются в MCP без релиза npm. Захардкоженные инструменты остаются для обратной совместимости; при совпадении
4
- // имени берётся версия из каталога (сервер гарантирует тот же смысл — контракт-тест реестра на бэкенде).
4
+ // имени остаётся локальная версия: каталог только ДОБАВЛЯЕТ новые имена (SEC-24), вызовы локальных
5
+ // инструментов идут через локальный handleCall.
5
6
  //
6
7
  // Кэш: в памяти + файл ~/.retensy-bot-graph/catalog-<хэш базы>.json (чтобы tools/list сразу после старта уже
7
8
  // знал каталог), обновление по TTL. Изменился набор — сервер шлёт notifications/tools/list_changed.
@@ -9,18 +10,38 @@
9
10
 
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
13
+ import crypto from "node:crypto";
12
14
 
13
15
  export const CATALOG_TTL_MS = 10 * 60 * 1000;
14
16
  const FETCH_TIMEOUT_MS = 6000;
15
17
  /** Служебные инструменты MCP: каталог их никогда не перекрывает. */
16
18
  const RESERVED = new Set(["setup", "set_token"]);
19
+ export const MAX_DESCRIPTION_BYTES = 2 * 1024;
20
+ export const MAX_SCHEMA_BYTES = 8 * 1024;
21
+ export const MAX_CATALOG_TOOLS = 300;
17
22
  const NAME_RX = /^[a-z][a-z0-9_]{2,63}$/;
18
23
 
19
24
  /** Валидная запись каталога: имя, описание, схема-объект. Остальное отбрасывается, а не ломает tools/list. */
20
25
  export function validCatalogTool(t) {
21
26
  return !!t && typeof t === "object" && typeof t.name === "string" && NAME_RX.test(t.name) && !RESERVED.has(t.name)
22
27
  && typeof t.description === "string" && t.description.trim().length > 0
23
- && t.inputSchema && typeof t.inputSchema === "object" && t.inputSchema.type === "object";
28
+ && Buffer.byteLength(t.description, "utf8") <= MAX_DESCRIPTION_BYTES
29
+ && t.inputSchema && typeof t.inputSchema === "object" && t.inputSchema.type === "object"
30
+ && Buffer.byteLength(JSON.stringify(t.inputSchema), "utf8") <= MAX_SCHEMA_BYTES;
31
+ }
32
+
33
+ const warned = new Set();
34
+ function warnOnce(key, msg) {
35
+ if (warned.has(key)) return;
36
+ warned.add(key);
37
+ console.error(`[retensy-mcp] ${msg}`);
38
+ }
39
+
40
+ /** Валидные записи каталога, не больше MAX_CATALOG_TOOLS. */
41
+ function sanitize(list) {
42
+ const valid = (Array.isArray(list) ? list : []).filter(validCatalogTool);
43
+ if (valid.length > MAX_CATALOG_TOOLS) warnOnce("max-tools", `каталог: ${valid.length} инструментов, берутся первые ${MAX_CATALOG_TOOLS}`);
44
+ return valid.slice(0, MAX_CATALOG_TOOLS);
24
45
  }
25
46
 
26
47
  /**
@@ -28,21 +49,42 @@ export function validCatalogTool(t) {
28
49
  * Порядок стабильный: сначала захардкоженные (как было), затем новые из каталога.
29
50
  */
30
51
  export function mergeTools(staticTools, catalogTools) {
31
- const byName = new Map((catalogTools || []).filter(validCatalogTool).map((t) => [t.name, t]));
32
- const out = staticTools.map((t) => {
33
- const c = byName.get(t.name);
34
- return c ? { name: c.name, description: c.description, inputSchema: c.inputSchema } : t;
35
- });
36
52
  const known = new Set(staticTools.map((t) => t.name));
37
- for (const c of byName.values()) {
38
- if (!known.has(c.name)) out.push({ name: c.name, description: c.description, inputSchema: c.inputSchema });
53
+ const out = [...staticTools];
54
+ for (const c of sanitize(catalogTools)) {
55
+ if (known.has(c.name)) {
56
+ warnOnce(`skip:${c.name}`, `каталог: инструмент ${c.name} уже есть локально, версия сервера пропущена`);
57
+ continue;
58
+ }
59
+ known.add(c.name);
60
+ const t = { name: c.name, description: c.description, inputSchema: c.inputSchema };
61
+ if (c.requiresConfirmation === true) t.annotations = { destructiveHint: true };
62
+ out.push(t);
39
63
  }
40
64
  return out;
41
65
  }
42
66
 
67
+ function isSafeBase(base) {
68
+ try {
69
+ const u = new URL(base);
70
+ if (u.protocol === "https:") return true;
71
+ return u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
72
+ } catch { return false; }
73
+ }
74
+
43
75
  export function createCatalog({ base, headers, configDir, enabled = true, now = () => Date.now(), fetchImpl = fetch }) {
44
- const file = path.join(configDir, `catalog-${hash(base)}.json`);
45
- let tools = readCache(file);
76
+ if (enabled && !isSafeBase(base)) {
77
+ warnOnce("insecure-base", "каталог выключен: для не-localhost RETENSY_BASE_URL нужен https");
78
+ enabled = false;
79
+ }
80
+ // Ключ кэша: база + хэш токена/куки, чтобы набор одного токена не виден другому.
81
+ const fileFor = () => {
82
+ const h = headers() || {};
83
+ const secret = `${h.Authorization || h.authorization || ""}|${h.Cookie || h.cookie || ""}`;
84
+ const tokenHash = crypto.createHash("sha256").update(secret).digest("hex").slice(0, 16);
85
+ return path.join(configDir, `catalog-${hash(base)}-${tokenHash}.json`);
86
+ };
87
+ let tools = enabled ? readCache(fileFor()) : [];
46
88
  let fetchedAt = 0;
47
89
  let inflight = null;
48
90
  let signature = sig(tools);
@@ -57,13 +99,15 @@ export function createCatalog({ base, headers, configDir, enabled = true, now =
57
99
  const res = await fetchImpl(`${base}/api/agent-tools/catalog`, { headers: headers(), signal: ctrl.signal });
58
100
  if (!res.ok) return false;
59
101
  const data = await res.json();
60
- const next = Array.isArray(data?.tools) ? data.tools.filter(validCatalogTool) : null;
102
+ const next = Array.isArray(data?.tools) ? sanitize(data.tools) : null;
61
103
  if (!next) return false;
62
104
  fetchedAt = now();
63
105
  tools = next;
64
106
  try {
65
107
  fs.mkdirSync(configDir, { recursive: true });
66
- fs.writeFileSync(file, JSON.stringify({ fetchedAt, tools }));
108
+ const file = fileFor();
109
+ fs.writeFileSync(file, JSON.stringify({ fetchedAt, tools }), { mode: 0o600 });
110
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows */ }
67
111
  } catch { /* кэш на диске — удобство, не обязанность */ }
68
112
  const s = sig(tools);
69
113
  const changed = s !== signature;
@@ -90,7 +134,7 @@ export function createCatalog({ base, headers, configDir, enabled = true, now =
90
134
  function readCache(file) {
91
135
  try {
92
136
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
93
- return Array.isArray(data?.tools) ? data.tools.filter(validCatalogTool) : [];
137
+ return Array.isArray(data?.tools) ? sanitize(data.tools) : [];
94
138
  } catch {
95
139
  return [];
96
140
  }
@@ -109,7 +153,11 @@ function hash(s) {
109
153
  /** Ответ invoke → результат MCP. 409 needsConfirmation — не ошибка сервиса, а просьба спросить человека. */
110
154
  export function confirmationText(data) {
111
155
  if (data && typeof data === "object" && data.needsConfirmation === true) {
112
- return `⚠️ Нужно подтверждение пользователя. ${typeof data.message === "string" ? data.message : ""}`.trim();
156
+ const msg = typeof data.message === "string" ? data.message : "";
157
+ if (typeof data.confirmToken === "string" && data.confirmToken) {
158
+ return `⚠️ Нужно подтверждение пользователя. ${msg} Опиши пользователю, что именно будет сделано, получи его явное согласие и повтори вызов с ТЕМИ ЖЕ аргументами плюс confirmToken: "${data.confirmToken}" (токен привязан к этим аргументам и действует 5 минут).`.trim();
159
+ }
160
+ return `⚠️ Нужно подтверждение пользователя. ${msg}`.trim();
113
161
  }
114
162
  return null;
115
163
  }
package/src/index.mjs CHANGED
@@ -1268,8 +1268,8 @@ const TOOLS = [
1268
1268
  { name: "agent_api_key_list", description: "Список API-ключей агента (GET /api/bots/agents/{agentId}/api-keys): {id, name, keyPrefix, createdAt, lastUsedAt} — без секрета (key:null), секрет виден только один раз при agent_api_key_create. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
1269
1269
  { name: "agent_api_key_revoke", description: "Отозвать API-ключ агента (DELETE /api/bots/agents/{agentId}/api-keys/{keyId}) — публичный чат с этим ключом сразу перестаёт отвечать (401). keyId — из agent_api_key_list. Необратимо: только по явной просьбе пользователя, с confirm:true.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, keyId: { type: "string" }, confirm: { type: "boolean" } }, required: ["agentId", "keyId", "confirm"] } },
1270
1270
  // ---- Пакеты ответов ИИ сверх тарифа (решение владельца 14.10) ----
1271
- { name: "ai_packs", description: "Пакеты ответов ИИ сверх месячного бюджета тарифа: каталог (GET /api/bots/ai-packs) — [{sku, answers, priceRub, kopecks}] (ai_pack_1000 — 1000 ответов/490₽, ai_pack_5000 — 5000/1990₽, ai_pack_20000 — 20000/6990₽; kopecks — внутренняя единица бюджета, справочно, не присылай её). Плюс баланс — aiPackAnswersLeft/aiPackTokensLeft из GET /api/bots/subscription: пакет не сгорает без активного тарифа, но списывается только при нём, ПОСЛЕ месячного бюджета тарифа, FIFO по старшинству покупки (сначала самый старый). Read-only; купить — ai_pack_purchase.", inputSchema: { type: "object", properties: {} } },
1272
- { name: "ai_pack_purchase", description: "ЭТО ПЛАТЁЖ, не покупка напрямую. Купить пакет ответов ИИ (POST /api/bots/ai-packs/purchase {sku, method}): тот же конвейер оплаты (ЮKassa/крипта), что у смены тарифа — ответ НЕ списывает деньги сам, а возвращает invoiceUrl, ссылку на оплату; пакет зачислится на баланс только после подтверждения платежа пользователем в браузере. sku — из ai_packs; method — \"card\" (по умолчанию), как у subscribe. Только по явной просьбе пользователя, с confirm:true.", inputSchema: { type: "object", properties: { sku: { type: "string", description: "sku из ai_packs, например ai_pack_5000" }, method: { type: "string", description: "card (по умолчанию) и т.п., как у subscribe" }, confirm: { type: "boolean" } }, required: ["sku", "confirm"] } },
1271
+ { name: "ai_packs", description: "Пакеты ответов ИИ сверх месячного бюджета тарифа: каталог (GET /api/bots/ai-packs): offers — разовые пакеты [{sku, priceRub, tokens, approxAnswers}], periodOffers — те же на 1/3/12 мес. (months, priceRub — итог за период со скидкой, savingsRub — экономия, tokens/approxAnswers — за МЕСЯЦ, начисляются помесячно) (ai_pack_1000 — 1000 ответов/490₽, ai_pack_5000 — 5000/1990₽, ai_pack_20000 — 20000/6990₽; kopecks — внутренняя единица бюджета, справочно, не присылай её). Плюс баланс — aiPackAnswersLeft/aiPackTokensLeft из GET /api/bots/subscription: пакет не сгорает без активного тарифа, но списывается только при нём, ПОСЛЕ месячного бюджета тарифа, FIFO по старшинству покупки (сначала самый старый). Read-only; купить — ai_pack_purchase.", inputSchema: { type: "object", properties: {} } },
1272
+ { name: "ai_pack_purchase", description: "ЭТО ПЛАТЁЖ, не покупка напрямую. Купить пакет ответов ИИ (POST /api/bots/ai-packs/purchase {sku, method}): тот же конвейер оплаты (ЮKassa/крипта), что у смены тарифа — ответ НЕ списывает деньги сам, а возвращает invoiceUrl, ссылку на оплату; пакет зачислится на баланс только после подтверждения платежа пользователем в браузере. sku — из ai_packs; method — \"card\" (по умолчанию), как у subscribe. Только по явной просьбе пользователя, с confirm:true.", inputSchema: { type: "object", properties: { sku: { type: "string", description: "sku из ai_packs, например ai_pack_5000" }, period: { type: "integer", enum: [1, 3, 12], description: "период, мес.: 1 (по умолчанию), 3 (скидка 10%) или 12 (скидка 20%) — токены начисляются каждый месяц, не сразу; можно вместо этого передать sku периода из periodOffers (ai_pack_5000_m3)" }, method: { type: "string", description: "card (по умолчанию) и т.п., как у subscribe" }, confirm: { type: "boolean" } }, required: ["sku", "confirm"] } },
1273
1273
  ];
1274
1274
 
1275
1275
  async function handleCall(params) {
@@ -2701,7 +2701,9 @@ async function handleCall(params) {
2701
2701
  const sku = String(a.sku ?? "").trim();
2702
2702
  if (!sku) throw new Error("Передай sku — из ai_packs.");
2703
2703
  if (a.confirm !== true) throw new Error("Это платёж. Спроси пользователя и повтори с confirm:true.");
2704
- const r = await api("/api/bots/ai-packs/purchase", { method: "POST", body: { sku, method: a.method || "card" } });
2704
+ const period = a.period === undefined ? undefined : Number(a.period);
2705
+ if (period !== undefined && ![1, 3, 12].includes(period)) throw new Error("period: 1, 3 или 12 месяцев.");
2706
+ const r = await api("/api/bots/ai-packs/purchase", { method: "POST", body: { sku, method: a.method || "card", ...(period ? { period } : {}) } });
2705
2707
  return okResult({ ...r, hint: "Пакет зачислится после подтверждения оплаты по invoiceUrl — ничего не списано автоматически." });
2706
2708
  }
2707
2709
  default:
@@ -2717,6 +2719,8 @@ const catalog = createCatalog({
2717
2719
  enabled: (process.env.RETENSY_MCP_CATALOG || "on").trim().toLowerCase() !== "off",
2718
2720
  });
2719
2721
 
2722
+ const LOCAL_TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
2723
+
2720
2724
  /** Инструмент каталога: сервер сам проверит права токена, тариф и подтверждение. */
2721
2725
  async function callCatalog(params) {
2722
2726
  try {
@@ -2755,7 +2759,7 @@ rl.on("line", async (line) => {
2755
2759
  } else if (method === "tools/call") {
2756
2760
  let result;
2757
2761
  try {
2758
- result = isAuthed() && catalog.has(params?.name) ? await callCatalog(params) : await handleCall(params);
2762
+ result = isAuthed() && catalog.has(params?.name) && !LOCAL_TOOL_NAMES.has(params?.name) ? await callCatalog(params) : await handleCall(params);
2759
2763
  if (params?.name === "set_token") refreshCatalog(); // новый токен — другой набор прав и действий
2760
2764
  } catch (e) {
2761
2765
  result = errResult(e);