@retensy/mcp 0.42.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 +3 -1
- package/package.json +1 -1
- package/src/catalog.mjs +63 -15
- package/src/index.mjs +3 -1
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
|
-
|
|
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.
|
|
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": {
|
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
|
-
&&
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
@@ -2717,6 +2717,8 @@ const catalog = createCatalog({
|
|
|
2717
2717
|
enabled: (process.env.RETENSY_MCP_CATALOG || "on").trim().toLowerCase() !== "off",
|
|
2718
2718
|
});
|
|
2719
2719
|
|
|
2720
|
+
const LOCAL_TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
|
|
2721
|
+
|
|
2720
2722
|
/** Инструмент каталога: сервер сам проверит права токена, тариф и подтверждение. */
|
|
2721
2723
|
async function callCatalog(params) {
|
|
2722
2724
|
try {
|
|
@@ -2755,7 +2757,7 @@ rl.on("line", async (line) => {
|
|
|
2755
2757
|
} else if (method === "tools/call") {
|
|
2756
2758
|
let result;
|
|
2757
2759
|
try {
|
|
2758
|
-
result = isAuthed() && catalog.has(params?.name) ? await callCatalog(params) : await handleCall(params);
|
|
2760
|
+
result = isAuthed() && catalog.has(params?.name) && !LOCAL_TOOL_NAMES.has(params?.name) ? await callCatalog(params) : await handleCall(params);
|
|
2759
2761
|
if (params?.name === "set_token") refreshCatalog(); // новый токен — другой набор прав и действий
|
|
2760
2762
|
} catch (e) {
|
|
2761
2763
|
result = errResult(e);
|