@retensy/mcp 0.41.0 → 0.43.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
@@ -441,6 +441,18 @@ RETENSY_MCP_TOKEN=zmcp_... node src/index.mjs # стартует stdio MCP-с
441
441
  Важно: запущенный процесс не может подменить собственный код — **обновление вступает в силу после
442
442
  перезапуска MCP-сервера**. Нет сети или npm недоступен — проверка молча пропускается, работа не ломается.
443
443
 
444
+ ### Каталог действий без обновления пакета
445
+
446
+ С версии 0.42 сервер при старте загружает единый каталог действий сервиса
447
+ (`GET /api/agent-tools/catalog` — тот же список, что у «Чата с ИИ» в кабинете) и регистрирует его
448
+ инструменты динамически; вызов идёт через `POST /api/agent-tools/invoke`, права токена, тариф и
449
+ подтверждение проверяет сервер. Новые действия появляются **без релиза npm**: каталог обновляется раз в
450
+ 10 минут, клиент получает `notifications/tools/list_changed`. Каталог кэшируется в
451
+ `~/.retensy-bot-graph/catalog-*.json`. Действия с подтверждением (удаление, рассылка всем, публикация)
452
+ повторяются с `confirmToken` из ответа сервера (привязан к аргументам, живёт 5 минут) только после явного согласия пользователя. Каталог только добавляет новые имена и не перекрывает встроенные инструменты; для не-localhost `RETENSY_BASE_URL` нужен https. Выключить: `RETENSY_MCP_CATALOG=off`.
453
+
454
+ Настоящее согласие человека для деструктивных инструментов - это подтверждение вызова в самом MCP-клиенте (такие инструменты помечены `destructiveHint`): не включайте для них автоодобрение.
455
+
444
456
  ## Отчёты о неудачах (телеметрия)
445
457
 
446
458
  Чтобы мы узнавали, каких возможностей не хватает, при неудаче инструмента отправляется **анонимный**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.41.0",
3
+ "version": "0.43.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": {
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "start": "node src/index.mjs",
23
23
  "check": "node --check src/index.mjs",
24
- "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/agent-write-tools.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.test.mjs && node scripts/graph-versions.test.mjs && node scripts/templates-filter.test.mjs && node scripts/agent-tests.test.mjs",
24
+ "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/agent-write-tools.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.test.mjs && node scripts/graph-versions.test.mjs && node scripts/templates-filter.test.mjs && node scripts/agent-tests.test.mjs && node scripts/catalog.test.mjs",
25
25
  "validate": "node skills/build-bot-funnel/validate.mjs"
26
26
  },
27
27
  "engines": {
@@ -0,0 +1,163 @@
1
+ // Единый каталог действий сервиса (GET /api/agent-tools/catalog) — тот же список, что у «Чата с ИИ» в кабинете.
2
+ // Инструменты каталога регистрируются динамически и вызываются через POST /api/agent-tools/invoke: новые действия
3
+ // появляются в MCP без релиза npm. Захардкоженные инструменты остаются для обратной совместимости; при совпадении
4
+ // имени остаётся локальная версия: каталог только ДОБАВЛЯЕТ новые имена (SEC-24), вызовы локальных
5
+ // инструментов идут через локальный handleCall.
6
+ //
7
+ // Кэш: в памяти + файл ~/.retensy-bot-graph/catalog-<хэш базы>.json (чтобы tools/list сразу после старта уже
8
+ // знал каталог), обновление по TTL. Изменился набор — сервер шлёт notifications/tools/list_changed.
9
+ // Выключить: RETENSY_MCP_CATALOG=off.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import crypto from "node:crypto";
14
+
15
+ export const CATALOG_TTL_MS = 10 * 60 * 1000;
16
+ const FETCH_TIMEOUT_MS = 6000;
17
+ /** Служебные инструменты MCP: каталог их никогда не перекрывает. */
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;
22
+ const NAME_RX = /^[a-z][a-z0-9_]{2,63}$/;
23
+
24
+ /** Валидная запись каталога: имя, описание, схема-объект. Остальное отбрасывается, а не ломает tools/list. */
25
+ export function validCatalogTool(t) {
26
+ return !!t && typeof t === "object" && typeof t.name === "string" && NAME_RX.test(t.name) && !RESERVED.has(t.name)
27
+ && typeof t.description === "string" && t.description.trim().length > 0
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);
45
+ }
46
+
47
+ /**
48
+ * Список tools/list: захардкоженные инструменты, кроме перекрытых каталогом, + инструменты каталога.
49
+ * Порядок стабильный: сначала захардкоженные (как было), затем новые из каталога.
50
+ */
51
+ export function mergeTools(staticTools, catalogTools) {
52
+ const known = new Set(staticTools.map((t) => t.name));
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);
63
+ }
64
+ return out;
65
+ }
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
+
75
+ export function createCatalog({ base, headers, configDir, enabled = true, now = () => Date.now(), fetchImpl = fetch }) {
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()) : [];
88
+ let fetchedAt = 0;
89
+ let inflight = null;
90
+ let signature = sig(tools);
91
+
92
+ async function refresh() {
93
+ if (!enabled) return false;
94
+ if (inflight) return inflight;
95
+ inflight = (async () => {
96
+ const ctrl = new AbortController();
97
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
98
+ try {
99
+ const res = await fetchImpl(`${base}/api/agent-tools/catalog`, { headers: headers(), signal: ctrl.signal });
100
+ if (!res.ok) return false;
101
+ const data = await res.json();
102
+ const next = Array.isArray(data?.tools) ? sanitize(data.tools) : null;
103
+ if (!next) return false;
104
+ fetchedAt = now();
105
+ tools = next;
106
+ try {
107
+ fs.mkdirSync(configDir, { recursive: true });
108
+ const file = fileFor();
109
+ fs.writeFileSync(file, JSON.stringify({ fetchedAt, tools }), { mode: 0o600 });
110
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows */ }
111
+ } catch { /* кэш на диске — удобство, не обязанность */ }
112
+ const s = sig(tools);
113
+ const changed = s !== signature;
114
+ signature = s;
115
+ return changed;
116
+ } catch {
117
+ return false;
118
+ } finally {
119
+ clearTimeout(timer);
120
+ inflight = null;
121
+ }
122
+ })();
123
+ return inflight;
124
+ }
125
+
126
+ return {
127
+ tools: () => (enabled ? tools : []),
128
+ has: (name) => enabled && tools.some((t) => t.name === name),
129
+ stale: () => enabled && now() - fetchedAt > CATALOG_TTL_MS,
130
+ refresh,
131
+ };
132
+ }
133
+
134
+ function readCache(file) {
135
+ try {
136
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
137
+ return Array.isArray(data?.tools) ? sanitize(data.tools) : [];
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
142
+
143
+ function sig(tools) {
144
+ return JSON.stringify((tools || []).map((t) => [t.name, t.description, t.inputSchema]));
145
+ }
146
+
147
+ function hash(s) {
148
+ let h = 0x811c9dc5;
149
+ for (const ch of String(s)) { h ^= ch.charCodeAt(0); h = (h * 0x01000193) >>> 0; }
150
+ return h.toString(16).padStart(8, "0");
151
+ }
152
+
153
+ /** Ответ invoke → результат MCP. 409 needsConfirmation — не ошибка сервиса, а просьба спросить человека. */
154
+ export function confirmationText(data) {
155
+ if (data && typeof data === "object" && data.needsConfirmation === true) {
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();
161
+ }
162
+ return null;
163
+ }
package/src/index.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { createInterface } from "node:readline";
24
24
  import { validateAgentTools } from "./agent-tools.mjs";
25
+ import { createCatalog, mergeTools, confirmationText } from "./catalog.mjs";
25
26
  import { fileURLToPath } from "node:url";
26
27
  import { spawn } from "node:child_process";
27
28
  import os from "node:os";
@@ -2708,6 +2709,34 @@ async function handleCall(params) {
2708
2709
  }
2709
2710
  }
2710
2711
 
2712
+ // ---- Единый каталог действий (тот же, что у «Чата с ИИ» в кабинете) ----
2713
+ const catalog = createCatalog({
2714
+ base: BASE,
2715
+ headers: authHeaders,
2716
+ configDir: CONFIG_DIR,
2717
+ enabled: (process.env.RETENSY_MCP_CATALOG || "on").trim().toLowerCase() !== "off",
2718
+ });
2719
+
2720
+ const LOCAL_TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
2721
+
2722
+ /** Инструмент каталога: сервер сам проверит права токена, тариф и подтверждение. */
2723
+ async function callCatalog(params) {
2724
+ try {
2725
+ return okResult(await api("/api/agent-tools/invoke", { method: "POST", body: { name: params.name, args: params.arguments || {} } }));
2726
+ } catch (e) {
2727
+ const text = e?.status === 409 ? confirmationText(e.data) : null;
2728
+ if (text) return okResult(text);
2729
+ throw e;
2730
+ }
2731
+ }
2732
+
2733
+ function notifyListChanged() { send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); }
2734
+ /** Обновить каталог в фоне; изменился набор — сказать клиенту перечитать tools/list. */
2735
+ function refreshCatalog() {
2736
+ if (!isAuthed()) return;
2737
+ catalog.refresh().then((changed) => { if (changed) notifyListChanged(); }).catch(() => {});
2738
+ }
2739
+
2711
2740
  // ---- JSON-RPC stdio (MCP) ----
2712
2741
  function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
2713
2742
 
@@ -2720,13 +2749,16 @@ rl.on("line", async (line) => {
2720
2749
  const { id, method, params } = req;
2721
2750
  try {
2722
2751
  if (method === "initialize") {
2723
- send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
2752
+ send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
2753
+ refreshCatalog();
2724
2754
  } else if (method === "tools/list") {
2725
- send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
2755
+ send({ jsonrpc: "2.0", id, result: { tools: mergeTools(TOOLS, catalog.tools()) } });
2756
+ if (catalog.stale()) refreshCatalog();
2726
2757
  } else if (method === "tools/call") {
2727
2758
  let result;
2728
2759
  try {
2729
- result = await handleCall(params);
2760
+ result = isAuthed() && catalog.has(params?.name) && !LOCAL_TOOL_NAMES.has(params?.name) ? await callCatalog(params) : await handleCall(params);
2761
+ if (params?.name === "set_token") refreshCatalog(); // новый токен — другой набор прав и действий
2730
2762
  } catch (e) {
2731
2763
  result = errResult(e);
2732
2764
  // Единая точка: сюда приходит ЛЮБАЯ неудача инструмента — в т.ч. «Неизвестный