omni-notify-mcp 1.0.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 +109 -0
- package/config.example.json +26 -0
- package/dist/channels/desktop.js +13 -0
- package/dist/channels/email.js +29 -0
- package/dist/channels/sms.js +7 -0
- package/dist/channels/telegram.js +11 -0
- package/dist/channels/whatsapp.js +11 -0
- package/dist/config.js +16 -0
- package/dist/index.js +146 -0
- package/dist/ui/server.js +708 -0
- package/package.json +62 -0
- package/ui/public/app.js +467 -0
- package/ui/public/index.html +192 -0
- package/ui/public/style.css +437 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# omni-notify-mcp
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server that lets AI assistants (Claude, Cursor, etc.) send notifications through multiple channels: **desktop**, **Telegram**, **WhatsApp**, **SMS**, and **email**.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx omni-notify-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, or `claude_desktop_config.json`):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"notify": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["omni-notify-mcp"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then create `~/.notify-mcp/config.json` (see [Configuration](#configuration)).
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
### `notify`
|
|
29
|
+
Send a notification. Priority controls which channels fire:
|
|
30
|
+
|
|
31
|
+
| Priority | Channels |
|
|
32
|
+
|----------|----------|
|
|
33
|
+
| `low` | email only |
|
|
34
|
+
| `normal` | desktop + Telegram + email |
|
|
35
|
+
| `high` | desktop + Telegram + WhatsApp + SMS + email |
|
|
36
|
+
|
|
37
|
+
### `ask`
|
|
38
|
+
Send a question and wait for a reply via Telegram.
|
|
39
|
+
|
|
40
|
+
### `poll`
|
|
41
|
+
Check the inbox for pending messages from the user.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Create `~/.notify-mcp/config.json`:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"desktop": {
|
|
50
|
+
"enabled": true
|
|
51
|
+
},
|
|
52
|
+
"telegram": {
|
|
53
|
+
"enabled": true,
|
|
54
|
+
"token": "YOUR_BOT_TOKEN",
|
|
55
|
+
"chatId": "YOUR_CHAT_ID"
|
|
56
|
+
},
|
|
57
|
+
"whatsapp": {
|
|
58
|
+
"enabled": false,
|
|
59
|
+
"instanceId": "YOUR_GREEN_API_INSTANCE_ID",
|
|
60
|
+
"apiToken": "YOUR_GREEN_API_TOKEN",
|
|
61
|
+
"phone": "+1234567890"
|
|
62
|
+
},
|
|
63
|
+
"sms": {
|
|
64
|
+
"enabled": false,
|
|
65
|
+
"accountSid": "YOUR_TWILIO_ACCOUNT_SID",
|
|
66
|
+
"authToken": "YOUR_TWILIO_AUTH_TOKEN",
|
|
67
|
+
"from": "+1YOUR_TWILIO_NUMBER",
|
|
68
|
+
"to": "+1YOUR_PERSONAL_NUMBER"
|
|
69
|
+
},
|
|
70
|
+
"email": {
|
|
71
|
+
"enabled": true,
|
|
72
|
+
"host": "smtp.gmail.com",
|
|
73
|
+
"port": 587,
|
|
74
|
+
"secure": false,
|
|
75
|
+
"user": "your-email@gmail.com",
|
|
76
|
+
"pass": "YOUR_GMAIL_APP_PASSWORD",
|
|
77
|
+
"to": "your-email@gmail.com"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Only enable the channels you need — disabled channels are silently skipped.
|
|
83
|
+
|
|
84
|
+
### Channel Setup
|
|
85
|
+
|
|
86
|
+
**Desktop** — works out of the box on macOS, Windows, and Linux.
|
|
87
|
+
|
|
88
|
+
**Telegram**
|
|
89
|
+
1. Create a bot via [@BotFather](https://t.me/botfather) → get a token
|
|
90
|
+
2. Message your bot, then get your chat ID:
|
|
91
|
+
```
|
|
92
|
+
https://api.telegram.org/bot<TOKEN>/getUpdates
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**WhatsApp** — uses [Green API](https://green-api.com). Create a free instance and paste the `instanceId` and `apiToken`.
|
|
96
|
+
|
|
97
|
+
**SMS** — uses [Twilio](https://twilio.com). Requires an account SID, auth token, and a Twilio phone number.
|
|
98
|
+
|
|
99
|
+
**Email (SMTP)** — works with any SMTP provider. For Gmail, use an [App Password](https://myaccount.google.com/apppasswords).
|
|
100
|
+
|
|
101
|
+
**Email (Gmail OAuth)** — run the built-in config UI for OAuth setup:
|
|
102
|
+
```bash
|
|
103
|
+
npx omni-notify-mcp ui
|
|
104
|
+
```
|
|
105
|
+
Then open http://localhost:3737.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"desktop": {
|
|
3
|
+
"enabled": true
|
|
4
|
+
},
|
|
5
|
+
"whatsapp": {
|
|
6
|
+
"enabled": true,
|
|
7
|
+
"phone": "+1234567890",
|
|
8
|
+
"apikey": "YOUR_CALLMEBOT_APIKEY"
|
|
9
|
+
},
|
|
10
|
+
"sms": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"accountSid": "YOUR_TWILIO_ACCOUNT_SID",
|
|
13
|
+
"authToken": "YOUR_TWILIO_AUTH_TOKEN",
|
|
14
|
+
"from": "+1YOUR_TWILIO_NUMBER",
|
|
15
|
+
"to": "+1YOUR_PERSONAL_NUMBER"
|
|
16
|
+
},
|
|
17
|
+
"email": {
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"host": "smtp.gmail.com",
|
|
20
|
+
"port": 587,
|
|
21
|
+
"secure": false,
|
|
22
|
+
"user": "your-email@gmail.com",
|
|
23
|
+
"pass": "YOUR_GMAIL_APP_PASSWORD",
|
|
24
|
+
"to": "your-email@gmail.com"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import notifier from "node-notifier";
|
|
2
|
+
export async function sendDesktop(config, message) {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
await new Promise((resolve, reject) => {
|
|
6
|
+
notifier.notify({ title: "Claude", message, sound: true }, (err) => {
|
|
7
|
+
if (err)
|
|
8
|
+
reject(err);
|
|
9
|
+
else
|
|
10
|
+
resolve();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
export async function sendEmail(config, message) {
|
|
3
|
+
if (!config.enabled)
|
|
4
|
+
return;
|
|
5
|
+
const transport = config.refreshToken && config.clientId && config.clientSecret
|
|
6
|
+
? nodemailer.createTransport({
|
|
7
|
+
service: "gmail",
|
|
8
|
+
auth: {
|
|
9
|
+
type: "OAuth2",
|
|
10
|
+
user: config.connectedEmail ?? config.to,
|
|
11
|
+
clientId: config.clientId,
|
|
12
|
+
clientSecret: config.clientSecret,
|
|
13
|
+
refreshToken: config.refreshToken,
|
|
14
|
+
accessToken: config.accessToken,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
: nodemailer.createTransport({
|
|
18
|
+
host: config.host,
|
|
19
|
+
port: config.port ?? 587,
|
|
20
|
+
secure: config.secure ?? false,
|
|
21
|
+
auth: { user: config.user, pass: config.pass },
|
|
22
|
+
});
|
|
23
|
+
await transport.sendMail({
|
|
24
|
+
from: config.from ?? config.connectedEmail ?? config.user,
|
|
25
|
+
to: config.to,
|
|
26
|
+
subject: "Claude notification",
|
|
27
|
+
text: message,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function sendTelegram(config, message) {
|
|
2
|
+
if (!config.enabled)
|
|
3
|
+
return;
|
|
4
|
+
const res = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "Content-Type": "application/json" },
|
|
7
|
+
body: JSON.stringify({ chat_id: config.chatId, text: message }),
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(`Telegram error ${res.status}: ${await res.text()}`);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function sendWhatsApp(config, message) {
|
|
2
|
+
if (!config.enabled)
|
|
3
|
+
return;
|
|
4
|
+
const res = await fetch(`https://api.green-api.com/waInstance${config.instanceId}/sendMessage/${config.apiToken}`, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "Content-Type": "application/json" },
|
|
7
|
+
body: JSON.stringify({ chatId: `${config.phone}@c.us`, message }),
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(`Green API error ${res.status}: ${await res.text()}`);
|
|
11
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
const CONFIG_PATHS = [
|
|
5
|
+
join(process.cwd(), "config.json"),
|
|
6
|
+
join(homedir(), ".notify-mcp", "config.json"),
|
|
7
|
+
];
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
for (const path of CONFIG_PATHS) {
|
|
10
|
+
if (existsSync(path)) {
|
|
11
|
+
const raw = readFileSync(path, "utf-8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`No config.json found. Run: npm run ui (then configure at http://localhost:3737)`);
|
|
16
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { sendDesktop } from "./channels/desktop.js";
|
|
8
|
+
import { sendTelegram } from "./channels/telegram.js";
|
|
9
|
+
import { sendWhatsApp } from "./channels/whatsapp.js";
|
|
10
|
+
import { sendSms } from "./channels/sms.js";
|
|
11
|
+
import { sendEmail } from "./channels/email.js";
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
// ── Telegram listener ─────────────────────────────────────────────────────────
|
|
14
|
+
const pendingAsks = new Map();
|
|
15
|
+
const inboxQueue = [];
|
|
16
|
+
let tgPollOffset = -1;
|
|
17
|
+
async function initTgOffset(token) {
|
|
18
|
+
const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
|
|
19
|
+
const json = await r.json();
|
|
20
|
+
const results = json.result ?? [];
|
|
21
|
+
return results.length > 0 ? results[results.length - 1].update_id + 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
async function startTelegramListener() {
|
|
24
|
+
while (true) {
|
|
25
|
+
try {
|
|
26
|
+
const cfg = loadConfig();
|
|
27
|
+
const { token, chatId } = cfg.telegram ?? {};
|
|
28
|
+
if (!token || !chatId || !cfg.telegram?.enabled) {
|
|
29
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (tgPollOffset < 0) {
|
|
33
|
+
tgPollOffset = await initTgOffset(token);
|
|
34
|
+
}
|
|
35
|
+
const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${tgPollOffset}&timeout=10`);
|
|
36
|
+
const json = await r.json();
|
|
37
|
+
for (const update of json.result ?? []) {
|
|
38
|
+
tgPollOffset = update.update_id + 1;
|
|
39
|
+
const msg = update.message;
|
|
40
|
+
if (msg?.chat?.id?.toString() === chatId && msg.text) {
|
|
41
|
+
const first = [...pendingAsks.entries()][0];
|
|
42
|
+
if (first) {
|
|
43
|
+
const [id, pending] = first;
|
|
44
|
+
clearTimeout(pending.timer);
|
|
45
|
+
pendingAsks.delete(id);
|
|
46
|
+
pending.resolve(msg.text);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
inboxQueue.push({ text: msg.text, ts: new Date().toISOString() });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
56
|
+
if (!msg.includes("terminated") && !msg.includes("aborted")) {
|
|
57
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
startTelegramListener();
|
|
63
|
+
// ── MCP server ────────────────────────────────────────────────────────────────
|
|
64
|
+
const server = new McpServer({
|
|
65
|
+
name: "omni-notify-mcp",
|
|
66
|
+
version: "1.0.0",
|
|
67
|
+
});
|
|
68
|
+
server.tool("notify", "Send a notification through configured channels (desktop, Telegram, WhatsApp, SMS, email). " +
|
|
69
|
+
"Priority: low=email only; normal=desktop+telegram+email; high=all channels. " +
|
|
70
|
+
"Use for: task milestones, questions needing user input, catastrophic findings, long task completion.", {
|
|
71
|
+
message: z.string().max(500).describe("Notification message, max 500 chars"),
|
|
72
|
+
priority: z
|
|
73
|
+
.enum(["low", "normal", "high"])
|
|
74
|
+
.default("normal")
|
|
75
|
+
.describe("low=email only; normal=desktop+telegram+email; high=desktop+telegram+whatsapp+sms+email"),
|
|
76
|
+
}, async ({ message, priority }) => {
|
|
77
|
+
const results = [];
|
|
78
|
+
const errors = [];
|
|
79
|
+
const send = async (name, fn) => {
|
|
80
|
+
try {
|
|
81
|
+
await fn();
|
|
82
|
+
results.push(name);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (priority === "normal" || priority === "high") {
|
|
89
|
+
await send("desktop", () => sendDesktop(config.desktop, message));
|
|
90
|
+
await send("telegram", () => sendTelegram(config.telegram, message));
|
|
91
|
+
}
|
|
92
|
+
if (priority === "high") {
|
|
93
|
+
await send("whatsapp", () => sendWhatsApp(config.whatsapp, message));
|
|
94
|
+
await send("sms", () => sendSms(config.sms, message));
|
|
95
|
+
}
|
|
96
|
+
await send("email", () => sendEmail(config.email, message));
|
|
97
|
+
const summary = [
|
|
98
|
+
results.length > 0 ? `Sent via: ${results.join(", ")}` : null,
|
|
99
|
+
errors.length > 0 ? `Errors: ${errors.join("; ")}` : null,
|
|
100
|
+
].filter(Boolean).join(" | ");
|
|
101
|
+
return { content: [{ type: "text", text: summary || "No channels delivered" }] };
|
|
102
|
+
});
|
|
103
|
+
server.tool("ask", "Send a question to the user via Telegram and wait for their reply. " +
|
|
104
|
+
"Use when a decision is needed before continuing — e.g. 'Should I delete these files?'", {
|
|
105
|
+
question: z.string().max(500).describe("The question to ask the user"),
|
|
106
|
+
timeout_seconds: z.number().min(30).max(3600).default(300)
|
|
107
|
+
.describe("How long to wait for a reply in seconds (default 5 min)"),
|
|
108
|
+
}, async ({ question, timeout_seconds = 300 }) => {
|
|
109
|
+
const cfg = loadConfig();
|
|
110
|
+
if (!cfg.telegram?.enabled || !cfg.telegram.token || !cfg.telegram.chatId) {
|
|
111
|
+
return { content: [{ type: "text", text: "Error: Telegram not configured. Enable it in ~/.notify-mcp/config.json" }] };
|
|
112
|
+
}
|
|
113
|
+
await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
chat_id: cfg.telegram.chatId,
|
|
118
|
+
text: `❓ ${question}\n\nReply to this message with your answer.`,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
const token = randomUUID();
|
|
122
|
+
const reply = await new Promise((resolve, reject) => {
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
pendingAsks.delete(token);
|
|
125
|
+
reject(new Error(`No reply received within ${timeout_seconds}s`));
|
|
126
|
+
}, timeout_seconds * 1000);
|
|
127
|
+
pendingAsks.set(token, { resolve, timer });
|
|
128
|
+
});
|
|
129
|
+
return { content: [{ type: "text", text: reply }] };
|
|
130
|
+
});
|
|
131
|
+
server.tool("poll", "Check for unsolicited messages the user sent on Telegram (not in response to an ask). " +
|
|
132
|
+
"Returns queued messages and clears the queue. Returns 'inbox:empty' if nothing pending. " +
|
|
133
|
+
"Call this at the start of each work cycle.", {}, async () => {
|
|
134
|
+
if (inboxQueue.length === 0) {
|
|
135
|
+
return { content: [{ type: "text", text: "inbox:empty" }] };
|
|
136
|
+
}
|
|
137
|
+
const messages = inboxQueue.splice(0);
|
|
138
|
+
return {
|
|
139
|
+
content: [{
|
|
140
|
+
type: "text",
|
|
141
|
+
text: messages.map(m => `[${m.ts}] ${m.text}`).join("\n"),
|
|
142
|
+
}],
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
const transport = new StdioServerTransport();
|
|
146
|
+
await server.connect(transport);
|