moab-notify 0.1.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 ADDED
@@ -0,0 +1,49 @@
1
+ # moab-notify (MCP)
2
+
3
+ MCP-сервер для управления уведомлениями moab.tools (`apprise.moab.tools`) — адресная книга
4
+ получателей (Telegram + email) из LLM. Доступ закрыт Keycloak-ролью `apprise-admin` (device-flow).
5
+
6
+ ## Инструменты
7
+
8
+ | Tool | Назначение |
9
+ |---|---|
10
+ | `login` / `auth_status` / `auth_logout` | вход через device-flow (auth.moab.tools, клиент `apprise-cli`) |
11
+ | `recipient_list` | показать адресную книгу |
12
+ | `recipient_set` | создать/изменить получателя (email + каналы; Telegram-only = только chat_id) |
13
+ | `recipient_remove` | удалить получателя |
14
+ | `notify_test` | тестовое уведомление по каналам получателя |
15
+
16
+ ## Запуск (в конфиге Claude)
17
+
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "moab-notify": { "command": "npx", "args": ["-y", "moab-notify"] }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## Конфиг (env, опционально)
27
+
28
+ | Переменная | Default |
29
+ |---|---|
30
+ | `NOTIFY_API_BASE` | `https://apprise.moab.tools` |
31
+ | `KEYCLOAK_AUTHORITY` | `https://auth.moab.tools/realms/moab` |
32
+ | `KEYCLOAK_CLIENT` | `apprise-cli` |
33
+ | `MOAB_CONFIG_DIR` | `~/.moab` (токены — `~/.moab/notify-mcp/credentials.json`, 0600) |
34
+
35
+ ## Разработка
36
+
37
+ ```bash
38
+ npm install
39
+ npm run build # tsc → dist/
40
+ npm test # vitest
41
+ ```
42
+
43
+ Релиз — публикация в npm (`npm publish`); `prepublishOnly` собирает `dist/`.
44
+
45
+ ## Как получить chat_id
46
+
47
+ Получатель жмёт **Start** у `@MoabAlertsBot`. Его numeric id (= chat_id для лички) — это его
48
+ Telegram user id. Временно: вытаскивается через `getUpdates` бота (см. `cluster-infra/deploy/apprise`);
49
+ позже — tool `pending_starts` (перенос getUpdates в gateway, follow-up).
@@ -0,0 +1,61 @@
1
+ import { ApiError } from './errors.js';
2
+ export class HttpApiClient {
3
+ cfg;
4
+ auth;
5
+ fetchFn;
6
+ constructor(cfg, auth, fetchFn = fetch) {
7
+ this.cfg = cfg;
8
+ this.auth = auth;
9
+ this.fetchFn = fetchFn;
10
+ }
11
+ listRecipients() {
12
+ return this.json('/admin/recipients', { method: 'GET' });
13
+ }
14
+ upsertRecipient(r) {
15
+ return this.json('/admin/recipients', this.jsonBody('PUT', r));
16
+ }
17
+ deleteRecipient(email) {
18
+ return this.json(`/admin/recipients/${encodeURIComponent(email)}`, { method: 'DELETE' });
19
+ }
20
+ testRecipient(email) {
21
+ return this.json(`/admin/test/${encodeURIComponent(email)}`, { method: 'POST' });
22
+ }
23
+ jsonBody(method, body) {
24
+ return { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
25
+ }
26
+ // ── низкоуровневое: bearer + один 401-retry + разбор {error} ──────────────
27
+ async request(path, init) {
28
+ const token = await this.auth.getAccessToken();
29
+ let res = await this.fetchFn(this.cfg.apiBase + path, this.withAuth(init, token));
30
+ if (res.status === 401) {
31
+ const fresh = await this.auth.invalidateAndRefresh();
32
+ res = await this.fetchFn(this.cfg.apiBase + path, this.withAuth(init, fresh));
33
+ }
34
+ return res;
35
+ }
36
+ async json(path, init) {
37
+ const res = await this.request(path, init);
38
+ return (await this.readOk(res));
39
+ }
40
+ withAuth(init, token) {
41
+ const headers = {
42
+ ...init.headers,
43
+ Authorization: `Bearer ${token}`,
44
+ Accept: 'application/json',
45
+ };
46
+ return { ...init, headers };
47
+ }
48
+ async readOk(res) {
49
+ const r = res.clone();
50
+ if (r.ok) {
51
+ const text = await r.text();
52
+ return text ? JSON.parse(text) : null;
53
+ }
54
+ let code = null;
55
+ try {
56
+ code = JSON.parse(await r.text()).error ?? null;
57
+ }
58
+ catch { /* нет тела */ }
59
+ throw new ApiError(code, res.status);
60
+ }
61
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,154 @@
1
+ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { AuthRequiredError, AuthPendingError } from './errors.js';
4
+ /** Хранит AuthState в <dir>/credentials.json с правами 0600 (posix). */
5
+ export class FileTokenStore {
6
+ dir;
7
+ file;
8
+ constructor(dir) {
9
+ this.dir = dir;
10
+ this.file = join(dir, 'credentials.json');
11
+ }
12
+ async load() {
13
+ try {
14
+ const raw = await readFile(this.file, 'utf8');
15
+ return JSON.parse(raw);
16
+ }
17
+ catch (e) {
18
+ if (e.code === 'ENOENT')
19
+ return null;
20
+ throw e;
21
+ }
22
+ }
23
+ async save(state) {
24
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
25
+ await writeFile(this.file, JSON.stringify(state, null, 2), { mode: 0o600 });
26
+ }
27
+ async clear() {
28
+ await rm(this.file, { force: true });
29
+ }
30
+ }
31
+ /** Запас перед истечением access — обновляем заранее. */
32
+ const EXPIRY_SKEW_MS = 30_000;
33
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
34
+ export class Authenticator {
35
+ cfg;
36
+ store;
37
+ fetchFn;
38
+ now;
39
+ constructor(cfg, store, fetchFn = fetch, now = () => Date.now()) {
40
+ this.cfg = cfg;
41
+ this.store = store;
42
+ this.fetchFn = fetchFn;
43
+ this.now = now;
44
+ }
45
+ deviceUrl() { return `${this.cfg.authority}/protocol/openid-connect/auth/device`; }
46
+ tokenUrl() { return `${this.cfg.authority}/protocol/openid-connect/token`; }
47
+ /** Стартует device flow, сохраняет pending, возвращает данные для показа пользователю. */
48
+ async startLogin() {
49
+ const res = await this.fetchFn(this.deviceUrl(), {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
52
+ body: new URLSearchParams({ client_id: this.cfg.client, scope: 'openid offline_access' }),
53
+ });
54
+ if (!res.ok)
55
+ throw new Error(`device flow start failed: ${res.status}`);
56
+ const dc = (await res.json());
57
+ await this.store.save({
58
+ pending: { deviceCode: dc.device_code, interval: dc.interval, deadline: this.now() + dc.expires_in * 1000 },
59
+ });
60
+ return dc;
61
+ }
62
+ /** Возвращает валидный access-токен: кэш → refresh → завершение pending. Иначе кидает Auth*Error. */
63
+ async getAccessToken() {
64
+ const state = await this.store.load();
65
+ if (!state)
66
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
67
+ if (state.tokens && state.tokens.expiresAt - this.now() > EXPIRY_SKEW_MS) {
68
+ return state.tokens.accessToken;
69
+ }
70
+ if (state.tokens) {
71
+ return this.refreshOrThrow(state.tokens.refreshToken);
72
+ }
73
+ if (state.pending) {
74
+ return this.completePendingOrThrow(state.pending);
75
+ }
76
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
77
+ }
78
+ /** Принудительный refresh (вызывается api-client при 401). */
79
+ async invalidateAndRefresh() {
80
+ const state = await this.store.load();
81
+ if (!state?.tokens)
82
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
83
+ return this.refreshOrThrow(state.tokens.refreshToken);
84
+ }
85
+ async status() {
86
+ const state = await this.store.load();
87
+ if (!state?.tokens)
88
+ return { loggedIn: false };
89
+ const username = decodeUsername(state.tokens.accessToken);
90
+ return username ? { loggedIn: true, username } : { loggedIn: true };
91
+ }
92
+ async logout() {
93
+ await this.store.clear();
94
+ }
95
+ async refreshOrThrow(refreshToken) {
96
+ const res = await this.fetchFn(this.tokenUrl(), {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
99
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.cfg.client }),
100
+ });
101
+ if (!res.ok) {
102
+ await this.store.clear();
103
+ throw new AuthRequiredError('Сессия истекла. Запустите инструмент login заново.');
104
+ }
105
+ const body = (await res.json());
106
+ const tokens = {
107
+ accessToken: body.access_token,
108
+ refreshToken: body.refresh_token,
109
+ expiresAt: this.now() + body.expires_in * 1000,
110
+ };
111
+ await this.store.save({ tokens });
112
+ return tokens.accessToken;
113
+ }
114
+ async completePendingOrThrow(pending) {
115
+ if (this.now() > pending.deadline) {
116
+ await this.store.clear();
117
+ throw new AuthRequiredError('Код подтверждения истёк. Запустите инструмент login заново.');
118
+ }
119
+ const res = await this.fetchFn(this.tokenUrl(), {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
122
+ body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: pending.deviceCode, client_id: this.cfg.client }),
123
+ });
124
+ if (res.ok) {
125
+ const body = (await res.json());
126
+ const tokens = {
127
+ accessToken: body.access_token,
128
+ refreshToken: body.refresh_token,
129
+ expiresAt: this.now() + body.expires_in * 1000,
130
+ };
131
+ await this.store.save({ tokens });
132
+ return tokens.accessToken;
133
+ }
134
+ const err = (await res.json().catch(() => ({})));
135
+ if (err.error === 'authorization_pending' || err.error === 'slow_down') {
136
+ throw new AuthPendingError('Вход ещё не подтверждён. Подтвердите в браузере и повторите запрос.');
137
+ }
138
+ await this.store.clear();
139
+ throw new AuthRequiredError('Не удалось войти. Запустите инструмент login заново.');
140
+ }
141
+ }
142
+ /** Достаёт preferred_username/email из payload JWT (без верификации — только для отображения). */
143
+ function decodeUsername(jwt) {
144
+ const parts = jwt.split('.');
145
+ if (parts.length < 2)
146
+ return undefined;
147
+ try {
148
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
149
+ return payload.preferred_username ?? payload.email ?? undefined;
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
package/dist/config.js ADDED
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export function loadConfig(env = process.env) {
4
+ const base = (env.NOTIFY_API_BASE ?? 'https://apprise.moab.tools').replace(/\/+$/, '');
5
+ const rootDir = env.MOAB_CONFIG_DIR ?? join(homedir(), '.moab');
6
+ return {
7
+ apiBase: base,
8
+ authority: env.KEYCLOAK_AUTHORITY ?? 'https://auth.moab.tools/realms/moab',
9
+ client: env.KEYCLOAK_CLIENT ?? 'apprise-cli',
10
+ configDir: join(rootDir, 'notify-mcp'),
11
+ };
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ // Тексты ошибок admin-API notify-gateway.
2
+ const ERR_RU = {
3
+ 'recipient not found': 'Получатель не найден в адресной книге',
4
+ 'recipient has no channels': 'У получателя не задан ни один канал (chat_id и/или email)',
5
+ 'email required': 'Не указан email получателя',
6
+ 'to required': 'Не указан адрес',
7
+ 'body required': 'Пустое тело сообщения',
8
+ 'not found': 'Не найдено',
9
+ };
10
+ export function ruError(code, status) {
11
+ if (code && ERR_RU[code])
12
+ return ERR_RU[code];
13
+ if (code)
14
+ return code;
15
+ if (status === 401)
16
+ return 'Не авторизовано (нет роли apprise-admin или истёк вход).';
17
+ if (status >= 500)
18
+ return 'Ошибка сервера, попробуйте ещё раз';
19
+ return 'Не удалось выполнить операцию';
20
+ }
21
+ /** Не-2xx ответ admin-API с телом {error: code}. */
22
+ export class ApiError extends Error {
23
+ code;
24
+ status;
25
+ constructor(code, status) {
26
+ super(`api_error code=${code ?? 'null'} status=${status}`);
27
+ this.code = code;
28
+ this.status = status;
29
+ this.name = 'ApiError';
30
+ }
31
+ }
32
+ /** Нет валидного токена — нужно запустить login. */
33
+ export class AuthRequiredError extends Error {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = 'AuthRequiredError';
37
+ }
38
+ }
39
+ /** Device flow стартован, но вход ещё не подтверждён в браузере. */
40
+ export class AuthPendingError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = 'AuthPendingError';
44
+ }
45
+ }
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { loadConfig } from './config.js';
4
+ import { FileTokenStore, Authenticator } from './auth.js';
5
+ import { HttpApiClient } from './api-client.js';
6
+ import { createServer } from './server.js';
7
+ async function main() {
8
+ const config = loadConfig();
9
+ const store = new FileTokenStore(config.configDir);
10
+ const auth = new Authenticator({ authority: config.authority, client: config.client }, store);
11
+ const api = new HttpApiClient(config, auth);
12
+ const server = createServer({ config, api, auth });
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
15
+ }
16
+ main().catch((e) => {
17
+ // stdout зарезервирован под MCP-протокол — диагностика только в stderr.
18
+ console.error('moab-notify fatal:', e);
19
+ process.exit(1);
20
+ });
package/dist/server.js ADDED
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerTools } from './tools/index.js';
3
+ export function createServer(deps) {
4
+ const server = new McpServer({ name: 'moab-notify', version: '0.1.0' });
5
+ registerTools(server, deps);
6
+ return server;
7
+ }
@@ -0,0 +1,34 @@
1
+ import { run, ok } from './index.js';
2
+ export function registerAuthTools(server, deps) {
3
+ server.registerTool('login', {
4
+ title: 'Войти',
5
+ description: 'Начать вход в управление уведомлениями через auth.moab.tools (device flow). Возвращает ссылку и код для подтверждения в браузере. Требуется роль apprise-admin.',
6
+ inputSchema: {},
7
+ }, async () => run(async () => {
8
+ const dc = await deps.auth.startLogin();
9
+ return [
10
+ 'Чтобы войти, откройте ссылку и подтвердите вход:',
11
+ dc.verification_uri_complete,
12
+ `Код подтверждения: ${dc.user_code}`,
13
+ '',
14
+ 'После подтверждения просто повторите нужную операцию — вход применится автоматически.',
15
+ ].join('\n');
16
+ }));
17
+ server.registerTool('auth_status', {
18
+ title: 'Статус входа',
19
+ description: 'Показать, выполнен ли вход и под каким пользователем.',
20
+ inputSchema: {},
21
+ annotations: { readOnlyHint: true },
22
+ }, async () => run(async () => {
23
+ const s = await deps.auth.status();
24
+ return s.loggedIn ? `Вы вошли как ${s.username ?? '(неизвестно)'}.` : 'Вход не выполнен. Запустите инструмент login.';
25
+ }));
26
+ server.registerTool('auth_logout', {
27
+ title: 'Выйти',
28
+ description: 'Удалить сохранённые токены входа.',
29
+ inputSchema: {},
30
+ }, async () => {
31
+ await deps.auth.logout();
32
+ return ok('Вы вышли. Сохранённые токены удалены.');
33
+ });
34
+ }
@@ -0,0 +1,28 @@
1
+ import { ApiError, AuthRequiredError, AuthPendingError, ruError } from '../errors.js';
2
+ import { registerAuthTools } from './auth-tools.js';
3
+ import { registerRecipientTools } from './recipients.js';
4
+ export function ok(text) {
5
+ return { content: [{ type: 'text', text }] };
6
+ }
7
+ export function fail(text) {
8
+ return { content: [{ type: 'text', text }], isError: true };
9
+ }
10
+ /** Единая обёртка: ловит Auth*Error/ApiError и превращает в дружелюбный текст. */
11
+ export async function run(fn) {
12
+ try {
13
+ return ok(await fn());
14
+ }
15
+ catch (e) {
16
+ if (e instanceof AuthRequiredError)
17
+ return fail(e.message);
18
+ if (e instanceof AuthPendingError)
19
+ return fail(e.message);
20
+ if (e instanceof ApiError)
21
+ return fail(ruError(e.code, e.status));
22
+ return fail(`Ошибка: ${e instanceof Error ? e.message : String(e)}`);
23
+ }
24
+ }
25
+ export function registerTools(server, deps) {
26
+ registerAuthTools(server, deps);
27
+ registerRecipientTools(server, deps);
28
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ import { run } from './index.js';
3
+ function channelsOf(r) {
4
+ const ch = [];
5
+ if (r.telegramChatId != null)
6
+ ch.push(`Telegram(${r.telegramChatId})`);
7
+ if (r.emailEnabled)
8
+ ch.push('email');
9
+ return ch.length ? ch.join(' + ') : '— нет каналов';
10
+ }
11
+ export function registerRecipientTools(server, deps) {
12
+ server.registerTool('recipient_list', {
13
+ title: 'Список получателей',
14
+ description: 'Показать адресную книгу: получатели и их каналы (Telegram / email).',
15
+ inputSchema: {},
16
+ annotations: { readOnlyHint: true },
17
+ }, async () => run(async () => {
18
+ const list = await deps.api.listRecipients();
19
+ if (list.length === 0)
20
+ return 'Адресная книга пуста.';
21
+ return list
22
+ .map((r) => `• ${r.email}${r.displayName ? ` (${r.displayName})` : ''} — ${channelsOf(r)}`)
23
+ .join('\n');
24
+ }));
25
+ server.registerTool('recipient_set', {
26
+ title: 'Создать/изменить получателя',
27
+ description: 'Завести получателя или обновить его каналы (по email-идентификатору). Указанные поля перезаписываются, неуказанные сохраняются. ' +
28
+ 'Telegram-only = задать только telegram_chat_id. chat_id берётся из @MoabAlertsBot (получатель должен нажать Start).',
29
+ inputSchema: {
30
+ email: z.string().email().describe('email-идентификатор получателя (он же адрес при email_enabled)'),
31
+ display_name: z.string().optional().describe('отображаемое имя'),
32
+ telegram_chat_id: z.number().int().optional().describe('Telegram chat_id (личный id пользователя)'),
33
+ email_enabled: z.boolean().optional().describe('слать ли на email этого получателя'),
34
+ },
35
+ }, async ({ email, display_name, telegram_chat_id, email_enabled }) => run(async () => {
36
+ const e = email.trim().toLowerCase();
37
+ const existing = (await deps.api.listRecipients()).find((r) => r.email === e);
38
+ const merged = {
39
+ email: e,
40
+ displayName: display_name !== undefined ? (display_name || null) : (existing?.displayName ?? null),
41
+ telegramChatId: telegram_chat_id !== undefined ? telegram_chat_id : (existing?.telegramChatId ?? null),
42
+ emailEnabled: email_enabled !== undefined ? email_enabled : (existing?.emailEnabled ?? false),
43
+ };
44
+ await deps.api.upsertRecipient(merged);
45
+ return `Сохранено: ${merged.email} — ${channelsOf(merged)}`;
46
+ }));
47
+ server.registerTool('recipient_remove', {
48
+ title: 'Удалить получателя',
49
+ description: 'Удалить получателя из адресной книги.',
50
+ inputSchema: {
51
+ email: z.string().email().describe('email-идентификатор получателя'),
52
+ },
53
+ annotations: { destructiveHint: true },
54
+ }, async ({ email }) => run(async () => {
55
+ await deps.api.deleteRecipient(email.trim().toLowerCase());
56
+ return `Удалён: ${email.trim().toLowerCase()}`;
57
+ }));
58
+ server.registerTool('notify_test', {
59
+ title: 'Тест-уведомление',
60
+ description: 'Отправить тестовое уведомление получателю по его каналам (из адресной книги).',
61
+ inputSchema: {
62
+ email: z.string().email().describe('email-идентификатор получателя'),
63
+ },
64
+ }, async ({ email }) => run(async () => {
65
+ const res = await deps.api.testRecipient(email.trim().toLowerCase());
66
+ const lines = res.dispatched.map((d) => ` ${d.channel}: ${d.ok ? 'ok' : 'FAIL'} (${d.status})`);
67
+ return [`Тест отправлен ${res.email}:`, ...lines].join('\n');
68
+ }));
69
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "moab-notify",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for moab.tools notifications (apprise.moab.tools) — manage notification recipients (Telegram + email) from an LLM.",
5
+ "type": "module",
6
+ "bin": { "moab-notify": "dist/index.js" },
7
+ "files": ["dist"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "test": "vitest run",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.12.0",
16
+ "zod": "^3.23.8"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.14.0",
20
+ "typescript": "^5.5.0",
21
+ "vitest": "^3.2.4"
22
+ },
23
+ "license": "UNLICENSED"
24
+ }