@retensy/mcp 0.40.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.
|
|
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": {
|
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
`hoursBefore` 1..720 для `reminder`, `hoursAfter` 1..168 для `after_visit` (`BOOKING_TRIGGER_BAD_HOURS`). Если запись сделана через бота,
|
|
105
105
|
сценарий идёт в диалоге клиента; иначе без получателя. Переменные: `{{booking.id}}`, `{{booking.event}}`, `{{booking.calendar}}`,
|
|
106
106
|
`{{booking.start}}`, `{{booking.end}}`, `{{booking.label}}`, `{{booking.name}}`, `{{booking.phone}}`. Каждое событие на запись срабатывает один раз.
|
|
107
|
+
- `TRIGGER_LINK_CLICK` — подписчик первый раз нажал отслеживаемую URL-кнопку (кнопка с `track:true`) в сообщении: `{ "sourceNodeId": "<uuid шага, необязательно = любой>", "buttonIdx": 0 }` (buttonIdx с нуля, необязательно). Сценарий идёт в диалоге кликнувшего; в шаблонах `{{link_click.url}}`, `{{link_click.buttonIdx}}`. Только основной сценарий бота.
|
|
107
108
|
- `TRIGGER_SITE_FORM` — `{ "siteId": "<uuid сайта>", "formId": "<id формы>" }` — заявка с формы
|
|
108
109
|
опубликованного сайта владельца. `formId` пустой/нет = любая форма сайта. `siteId` обязателен
|
|
109
110
|
(`SITE_FORM_NO_SITE`), сайт должен быть свой (`SITE_FORM_FOREIGN_SITE`). Переменные: поля формы
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
`amount` (`YK_NO_AMOUNT`/`YK_BAD_AMOUNT`), `description` (`YK_NO_DESC`).
|
|
90
90
|
- `TRIGGER_BOOKING`: `events` непустой список из booked|confirmed|cancelled|reminder|after_visit (`BOOKING_TRIGGER_NO_EVENTS`, `BOOKING_TRIGGER_BAD_EVENT`);
|
|
91
91
|
`calendarId` UUID или пусто (`BOOKING_TRIGGER_BAD_CALENDAR`); `hoursBefore` 1..720, `hoursAfter` 1..168 (`BOOKING_TRIGGER_BAD_HOURS`).
|
|
92
|
+
- `TRIGGER_LINK_CLICK`: `sourceNodeId` (если задан) - UUID шага (`LINK_CLICK_BAD_SOURCE`), `buttonIdx` (если задан) - целое 0..99 (`LINK_CLICK_BAD_BUTTON`).
|
|
92
93
|
- `start_broadcast` (в `ACTIONS`): `draftId` UUID (`BROADCAST_BAD_DRAFT`), `segmentId` UUID или пусто (`BROADCAST_BAD_SEGMENT`),
|
|
93
94
|
`minIntervalHours` целое 1..720 (`BROADCAST_BAD_INTERVAL`).
|
|
94
95
|
- `TRIGGER_PAYMENT`: обязательных полей нет. Фильтры `minAmount`/`descriptionContains`
|
package/src/catalog.mjs
ADDED
|
@@ -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
|
// Единая точка: сюда приходит ЛЮБАЯ неудача инструмента — в т.ч. «Неизвестный
|