@retensy/mcp 0.41.0 → 0.42.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,16 @@ 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
+ выполняются только с `confirm:true` после явного согласия пользователя. Выключить: `RETENSY_MCP_CATALOG=off`.
453
+
444
454
  ## Отчёты о неудачах (телеметрия)
445
455
 
446
456
  Чтобы мы узнавали, каких возможностей не хватает, при неудаче инструмента отправляется **анонимный**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.41.0",
3
+ "version": "0.42.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,115 @@
1
+ // Единый каталог действий сервиса (GET /api/agent-tools/catalog) — тот же список, что у «Чата с ИИ» в кабинете.
2
+ // Инструменты каталога регистрируются динамически и вызываются через POST /api/agent-tools/invoke: новые действия
3
+ // появляются в MCP без релиза npm. Захардкоженные инструменты остаются для обратной совместимости; при совпадении
4
+ // имени берётся версия из каталога (сервер гарантирует тот же смысл — контракт-тест реестра на бэкенде).
5
+ //
6
+ // Кэш: в памяти + файл ~/.retensy-bot-graph/catalog-<хэш базы>.json (чтобы tools/list сразу после старта уже
7
+ // знал каталог), обновление по TTL. Изменился набор — сервер шлёт notifications/tools/list_changed.
8
+ // Выключить: RETENSY_MCP_CATALOG=off.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export const CATALOG_TTL_MS = 10 * 60 * 1000;
14
+ const FETCH_TIMEOUT_MS = 6000;
15
+ /** Служебные инструменты MCP: каталог их никогда не перекрывает. */
16
+ const RESERVED = new Set(["setup", "set_token"]);
17
+ const NAME_RX = /^[a-z][a-z0-9_]{2,63}$/;
18
+
19
+ /** Валидная запись каталога: имя, описание, схема-объект. Остальное отбрасывается, а не ломает tools/list. */
20
+ export function validCatalogTool(t) {
21
+ return !!t && typeof t === "object" && typeof t.name === "string" && NAME_RX.test(t.name) && !RESERVED.has(t.name)
22
+ && typeof t.description === "string" && t.description.trim().length > 0
23
+ && t.inputSchema && typeof t.inputSchema === "object" && t.inputSchema.type === "object";
24
+ }
25
+
26
+ /**
27
+ * Список tools/list: захардкоженные инструменты, кроме перекрытых каталогом, + инструменты каталога.
28
+ * Порядок стабильный: сначала захардкоженные (как было), затем новые из каталога.
29
+ */
30
+ 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
+ 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 });
39
+ }
40
+ return out;
41
+ }
42
+
43
+ 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);
46
+ let fetchedAt = 0;
47
+ let inflight = null;
48
+ let signature = sig(tools);
49
+
50
+ async function refresh() {
51
+ if (!enabled) return false;
52
+ if (inflight) return inflight;
53
+ inflight = (async () => {
54
+ const ctrl = new AbortController();
55
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
56
+ try {
57
+ const res = await fetchImpl(`${base}/api/agent-tools/catalog`, { headers: headers(), signal: ctrl.signal });
58
+ if (!res.ok) return false;
59
+ const data = await res.json();
60
+ const next = Array.isArray(data?.tools) ? data.tools.filter(validCatalogTool) : null;
61
+ if (!next) return false;
62
+ fetchedAt = now();
63
+ tools = next;
64
+ try {
65
+ fs.mkdirSync(configDir, { recursive: true });
66
+ fs.writeFileSync(file, JSON.stringify({ fetchedAt, tools }));
67
+ } catch { /* кэш на диске — удобство, не обязанность */ }
68
+ const s = sig(tools);
69
+ const changed = s !== signature;
70
+ signature = s;
71
+ return changed;
72
+ } catch {
73
+ return false;
74
+ } finally {
75
+ clearTimeout(timer);
76
+ inflight = null;
77
+ }
78
+ })();
79
+ return inflight;
80
+ }
81
+
82
+ return {
83
+ tools: () => (enabled ? tools : []),
84
+ has: (name) => enabled && tools.some((t) => t.name === name),
85
+ stale: () => enabled && now() - fetchedAt > CATALOG_TTL_MS,
86
+ refresh,
87
+ };
88
+ }
89
+
90
+ function readCache(file) {
91
+ try {
92
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
93
+ return Array.isArray(data?.tools) ? data.tools.filter(validCatalogTool) : [];
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ function sig(tools) {
100
+ return JSON.stringify((tools || []).map((t) => [t.name, t.description, t.inputSchema]));
101
+ }
102
+
103
+ function hash(s) {
104
+ let h = 0x811c9dc5;
105
+ for (const ch of String(s)) { h ^= ch.charCodeAt(0); h = (h * 0x01000193) >>> 0; }
106
+ return h.toString(16).padStart(8, "0");
107
+ }
108
+
109
+ /** Ответ invoke → результат MCP. 409 needsConfirmation — не ошибка сервиса, а просьба спросить человека. */
110
+ export function confirmationText(data) {
111
+ if (data && typeof data === "object" && data.needsConfirmation === true) {
112
+ return `⚠️ Нужно подтверждение пользователя. ${typeof data.message === "string" ? data.message : ""}`.trim();
113
+ }
114
+ return null;
115
+ }
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,32 @@ 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
+ /** Инструмент каталога: сервер сам проверит права токена, тариф и подтверждение. */
2721
+ async function callCatalog(params) {
2722
+ try {
2723
+ return okResult(await api("/api/agent-tools/invoke", { method: "POST", body: { name: params.name, args: params.arguments || {} } }));
2724
+ } catch (e) {
2725
+ const text = e?.status === 409 ? confirmationText(e.data) : null;
2726
+ if (text) return okResult(text);
2727
+ throw e;
2728
+ }
2729
+ }
2730
+
2731
+ function notifyListChanged() { send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); }
2732
+ /** Обновить каталог в фоне; изменился набор — сказать клиенту перечитать tools/list. */
2733
+ function refreshCatalog() {
2734
+ if (!isAuthed()) return;
2735
+ catalog.refresh().then((changed) => { if (changed) notifyListChanged(); }).catch(() => {});
2736
+ }
2737
+
2711
2738
  // ---- JSON-RPC stdio (MCP) ----
2712
2739
  function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
2713
2740
 
@@ -2720,13 +2747,16 @@ rl.on("line", async (line) => {
2720
2747
  const { id, method, params } = req;
2721
2748
  try {
2722
2749
  if (method === "initialize") {
2723
- send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
2750
+ send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: { listChanged: true } }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
2751
+ refreshCatalog();
2724
2752
  } else if (method === "tools/list") {
2725
- send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
2753
+ send({ jsonrpc: "2.0", id, result: { tools: mergeTools(TOOLS, catalog.tools()) } });
2754
+ if (catalog.stale()) refreshCatalog();
2726
2755
  } else if (method === "tools/call") {
2727
2756
  let result;
2728
2757
  try {
2729
- result = await handleCall(params);
2758
+ result = isAuthed() && catalog.has(params?.name) ? await callCatalog(params) : await handleCall(params);
2759
+ if (params?.name === "set_token") refreshCatalog(); // новый токен — другой набор прав и действий
2730
2760
  } catch (e) {
2731
2761
  result = errResult(e);
2732
2762
  // Единая точка: сюда приходит ЛЮБАЯ неудача инструмента — в т.ч. «Неизвестный