omni-notify-mcp 1.1.5 → 1.1.7
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 +1 -1
- package/assets/screenshots/main-ui.png +0 -0
- package/dist/ui/server.js +69 -2
- package/package.json +1 -1
- package/ui/public/app.js +50 -6
- package/ui/public/index.html +4 -0
- package/ui/public/style.css +28 -6
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ Priority routing for `notify`:
|
|
|
86
86
|
## Features
|
|
87
87
|
|
|
88
88
|
### Channels
|
|
89
|
-
- **Desktop** — native `node-notifier` (macOS/Windows/Linux). Per-channel **system-sound toggle** and optional **text-to-speech**
|
|
89
|
+
- **Desktop** — native `node-notifier` (macOS/Windows/Linux). Per-channel **system-sound toggle** and optional **text-to-speech** with a voice picker covering 30+ neural voices (US/UK/AU/CA/IN/IE/NZ/…) via `msedge-tts`, no API key.
|
|
90
90
|
- **Telegram** — bidirectional. The bot **replies in-thread** to user messages and acknowledges every inbound message so the user knows it landed.
|
|
91
91
|
- **SMS** — Twilio.
|
|
92
92
|
- **Email** — Gmail App Password (one click) or any SMTP. `ask` over email sends a reply link the user clicks to answer.
|
|
Binary file
|
package/dist/ui/server.js
CHANGED
|
@@ -190,10 +190,12 @@ async function speakText(text, voice) {
|
|
|
190
190
|
spawn("aplay", [audioFilePath], { stdio: "ignore" });
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
|
-
app.post("/api/test/tts", async (
|
|
193
|
+
app.post("/api/test/tts", async (req, res) => {
|
|
194
194
|
try {
|
|
195
195
|
const cfg = loadConfig();
|
|
196
|
-
const voice =
|
|
196
|
+
const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
|
|
197
|
+
cfg.desktop?.ttsVoice ||
|
|
198
|
+
"en-US-AndrewMultilingualNeural";
|
|
197
199
|
await speakText("Notification from Claude. This is a voice test.", voice);
|
|
198
200
|
res.json({ ok: true, message: `TTS played (${voice})` });
|
|
199
201
|
}
|
|
@@ -201,6 +203,26 @@ app.post("/api/test/tts", async (_req, res) => {
|
|
|
201
203
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
202
204
|
}
|
|
203
205
|
});
|
|
206
|
+
let voiceCache = null;
|
|
207
|
+
app.get("/api/voices", async (_req, res) => {
|
|
208
|
+
try {
|
|
209
|
+
if (!voiceCache || Date.now() - voiceCache.ts > 24 * 60 * 60 * 1000) {
|
|
210
|
+
const mod = await import("msedge-tts");
|
|
211
|
+
const tts = new mod.MsEdgeTTS();
|
|
212
|
+
const all = await tts.getVoices();
|
|
213
|
+
voiceCache = {
|
|
214
|
+
ts: Date.now(),
|
|
215
|
+
voices: all
|
|
216
|
+
.filter((v) => v.Locale.startsWith("en-") && v.ShortName.includes("Neural"))
|
|
217
|
+
.map((v) => ({ shortName: v.ShortName, gender: v.Gender, locale: v.Locale })),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
res.json({ voices: voiceCache.voices });
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
204
226
|
app.post("/api/test/desktop", (_req, res) => {
|
|
205
227
|
const time = new Date().toLocaleTimeString();
|
|
206
228
|
const cfg = loadConfig();
|
|
@@ -948,6 +970,51 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
948
970
|
6. The 'get_idle_*' and 'get_dnd_status' tools are informational. You can
|
|
949
971
|
inspect them if you want to explain a delivery decision, but they are NOT
|
|
950
972
|
required pre-flights — the server gates server-side.
|
|
973
|
+
|
|
974
|
+
7. If your tool call fails with "MCP server not connected" / "transport
|
|
975
|
+
closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
|
|
976
|
+
connected to the same server right now. Only YOUR client's transport
|
|
977
|
+
broke (commonly: stdio child process died, or HTTP client gave up after a
|
|
978
|
+
transient slow response at startup). DO NOT tell the user "notify is down"
|
|
979
|
+
without first probing the server directly.
|
|
980
|
+
|
|
981
|
+
Probe before declaring the server dead:
|
|
982
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3737/mcp
|
|
983
|
+
- 406 (Not Acceptable) = server is healthy, doing MCP protocol
|
|
984
|
+
negotiation. Your client is the broken party. Use the bypass below.
|
|
985
|
+
- Connection refused / timeout = server is genuinely down. Tell the user.
|
|
986
|
+
|
|
987
|
+
HTTP-transport bypass when your MCP client is broken (works from any
|
|
988
|
+
shell that has curl). Three calls, stateful — the session id from step 1
|
|
989
|
+
must be passed in mcp-session-id on every subsequent call:
|
|
990
|
+
|
|
991
|
+
# 1) initialize and capture session id from response headers
|
|
992
|
+
SID=$(curl -s -i -X POST http://localhost:3737/mcp \
|
|
993
|
+
-H "Content-Type: application/json" \
|
|
994
|
+
-H "Accept: application/json, text/event-stream" \
|
|
995
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-bridge","version":"1.0"}}}' \
|
|
996
|
+
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
|
|
997
|
+
|
|
998
|
+
# 2) ack initialized (no response body, fire-and-forget)
|
|
999
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
1000
|
+
-H "Content-Type: application/json" \
|
|
1001
|
+
-H "Accept: application/json, text/event-stream" \
|
|
1002
|
+
-H "mcp-session-id: $SID" \
|
|
1003
|
+
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
|
|
1004
|
+
|
|
1005
|
+
# 3) call any tool — successful response includes "Sent via: <channel>"
|
|
1006
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
1007
|
+
-H "Content-Type: application/json" \
|
|
1008
|
+
-H "Accept: application/json, text/event-stream" \
|
|
1009
|
+
-H "mcp-session-id: $SID" \
|
|
1010
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notify","arguments":{"message":"YOUR MESSAGE","priority":"normal"}}}'
|
|
1011
|
+
|
|
1012
|
+
Same pattern works for 'ask', 'poll', 'get_idle_seconds', etc. — change
|
|
1013
|
+
the "name" and "arguments" in step 3. Reuse the same $SID for all calls
|
|
1014
|
+
in one bypass session.
|
|
1015
|
+
|
|
1016
|
+
Bottom line: rule #1 still applies. If you owe the user a notify and your
|
|
1017
|
+
MCP client is broken, you owe them the bypass — not an excuse.
|
|
951
1018
|
`.trim();
|
|
952
1019
|
function createMcpServer(clientId, sessionTag) {
|
|
953
1020
|
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: MCP_INSTRUCTIONS });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "An MCP server that lets AI agents (Claude, Cursor, etc.) reach you on any channel — desktop, Telegram, SMS, email — with two-way ask/reply, real-time inbox push, Do Not Disturb, idle gating, multi-session routing, and a one-page web UI for setup. Zero config code; configure once, agents call notify/ask.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
package/ui/public/app.js
CHANGED
|
@@ -45,6 +45,8 @@ function populateForm() {
|
|
|
45
45
|
$("desktop-enabled").checked = !!config.desktop?.enabled;
|
|
46
46
|
$("desktop-sound").checked = config.desktop?.sound !== false; // default on
|
|
47
47
|
$("desktop-tts").checked = !!config.desktop?.tts; // default off
|
|
48
|
+
updateTtsVoiceRow();
|
|
49
|
+
loadVoices().catch(() => {});
|
|
48
50
|
|
|
49
51
|
// Email / Gmail
|
|
50
52
|
const email = config.email ?? {};
|
|
@@ -115,14 +117,16 @@ function updateBadges() {
|
|
|
115
117
|
email.connectedEmail ? "Connected" : email.clientId ? "Credentials saved" : "Not configured");
|
|
116
118
|
|
|
117
119
|
const tg = config.telegram ?? {};
|
|
120
|
+
const tgReady = tg.token && tg.chatId;
|
|
118
121
|
setBadge("telegram",
|
|
119
|
-
tg.enabled &&
|
|
120
|
-
tg.enabled &&
|
|
122
|
+
tg.enabled && tgReady ? "ok" : tgReady ? "warn" : tg.token ? "warn" : "idle",
|
|
123
|
+
tg.enabled && tgReady ? "Configured" : tgReady ? "Disabled" : tg.token ? "Incomplete" : "Not configured");
|
|
121
124
|
|
|
122
|
-
const sms = config.sms ?? {};
|
|
125
|
+
const sms = config.sms ?? {};
|
|
126
|
+
const smsReady = sms.accountSid && sms.authToken;
|
|
123
127
|
setBadge("sms",
|
|
124
|
-
sms.enabled &&
|
|
125
|
-
sms.enabled &&
|
|
128
|
+
sms.enabled && smsReady ? "ok" : smsReady ? "warn" : sms.accountSid ? "warn" : "idle",
|
|
129
|
+
sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
|
|
126
130
|
|
|
127
131
|
// DND badge: "Active" (red), "Scheduled" (warn), or "Off" (idle)
|
|
128
132
|
const dnd = config.dnd ?? {};
|
|
@@ -151,15 +155,50 @@ function setBadge(channel, type, text) {
|
|
|
151
155
|
// ── Save handlers ─────────────────────────────────────────────────────────
|
|
152
156
|
|
|
153
157
|
function saveDesktop() {
|
|
158
|
+
updateTtsVoiceRow();
|
|
159
|
+
const ttsVoice = $("desktop-tts-voice").value || undefined;
|
|
154
160
|
patch({
|
|
155
161
|
desktop: {
|
|
156
162
|
enabled: $("desktop-enabled").checked,
|
|
157
163
|
sound: $("desktop-sound").checked,
|
|
158
164
|
tts: $("desktop-tts").checked,
|
|
165
|
+
ttsVoice,
|
|
159
166
|
},
|
|
160
167
|
});
|
|
161
168
|
}
|
|
162
169
|
|
|
170
|
+
function updateTtsVoiceRow() {
|
|
171
|
+
const row = $("tts-voice-row");
|
|
172
|
+
row.style.display = $("desktop-tts").checked ? "" : "none";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let voicesLoaded = false;
|
|
176
|
+
async function loadVoices() {
|
|
177
|
+
if (voicesLoaded) return;
|
|
178
|
+
const res = await fetch("/api/voices");
|
|
179
|
+
if (!res.ok) return;
|
|
180
|
+
const { voices } = await res.json();
|
|
181
|
+
const sel = $("desktop-tts-voice");
|
|
182
|
+
const current = config.desktop?.ttsVoice || "en-US-AndrewMultilingualNeural";
|
|
183
|
+
const byLocale = {};
|
|
184
|
+
for (const v of voices) (byLocale[v.locale] ??= []).push(v);
|
|
185
|
+
sel.innerHTML = "";
|
|
186
|
+
for (const locale of Object.keys(byLocale).sort()) {
|
|
187
|
+
const og = document.createElement("optgroup");
|
|
188
|
+
og.label = locale;
|
|
189
|
+
for (const v of byLocale[locale].sort((a, b) => a.shortName.localeCompare(b.shortName))) {
|
|
190
|
+
const opt = document.createElement("option");
|
|
191
|
+
opt.value = v.shortName;
|
|
192
|
+
const name = v.shortName.replace(locale + "-", "").replace(/Neural$/, "").replace(/Multilingual$/, " (Multi)");
|
|
193
|
+
opt.textContent = `${name} · ${v.gender}`;
|
|
194
|
+
if (v.shortName === current) opt.selected = true;
|
|
195
|
+
og.appendChild(opt);
|
|
196
|
+
}
|
|
197
|
+
sel.appendChild(og);
|
|
198
|
+
}
|
|
199
|
+
voicesLoaded = true;
|
|
200
|
+
}
|
|
201
|
+
|
|
163
202
|
async function saveEmail() {
|
|
164
203
|
const to = $("gmail-to-connected").value.trim();
|
|
165
204
|
const enabled = $("email-enabled").checked;
|
|
@@ -402,7 +441,12 @@ async function testSound() {
|
|
|
402
441
|
|
|
403
442
|
async function testTts() {
|
|
404
443
|
try {
|
|
405
|
-
const
|
|
444
|
+
const voice = $("desktop-tts-voice").value || undefined;
|
|
445
|
+
const res = await fetch("/api/test/tts", {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "Content-Type": "application/json" },
|
|
448
|
+
body: JSON.stringify({ voice }),
|
|
449
|
+
});
|
|
406
450
|
const json = await res.json();
|
|
407
451
|
if (!res.ok) throw new Error(json.error);
|
|
408
452
|
toast(json.message, "ok");
|
package/ui/public/index.html
CHANGED
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
<span class="toggle-lbl">Speak notification</span>
|
|
64
64
|
<button class="btn btn-sm btn-ghost" onclick="testTts()">Test voice</button>
|
|
65
65
|
</div>
|
|
66
|
+
<div id="tts-voice-row" class="fg" style="margin-top:8px; display:none">
|
|
67
|
+
<label>Voice</label>
|
|
68
|
+
<select id="desktop-tts-voice" onchange="saveDesktop()"></select>
|
|
69
|
+
</div>
|
|
66
70
|
<span id="os-hint" class="os-tag hidden"></span>
|
|
67
71
|
</div>
|
|
68
72
|
</div>
|
package/ui/public/style.css
CHANGED
|
@@ -102,11 +102,11 @@ main {
|
|
|
102
102
|
flex: 1;
|
|
103
103
|
min-height: 0;
|
|
104
104
|
height: 100%;
|
|
105
|
-
overflow:
|
|
106
|
-
display:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
align-content:
|
|
105
|
+
overflow: auto;
|
|
106
|
+
display: grid;
|
|
107
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
108
|
+
grid-auto-rows: min-content;
|
|
109
|
+
align-content: start;
|
|
110
110
|
gap: 12px;
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -207,8 +207,9 @@ main {
|
|
|
207
207
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
208
208
|
|
|
209
209
|
.card {
|
|
210
|
-
width:
|
|
210
|
+
width: auto;
|
|
211
211
|
max-width: 100%;
|
|
212
|
+
min-width: 0;
|
|
212
213
|
background: var(--surface);
|
|
213
214
|
border: 2px solid #4a4a5a;
|
|
214
215
|
border-radius: var(--r);
|
|
@@ -362,6 +363,27 @@ input[type="time"] {
|
|
|
362
363
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
|
363
364
|
}
|
|
364
365
|
input::placeholder { color: #cdcdda; opacity: 1; }
|
|
366
|
+
|
|
367
|
+
select {
|
|
368
|
+
width: 100%;
|
|
369
|
+
max-width: 100%;
|
|
370
|
+
box-sizing: border-box;
|
|
371
|
+
padding: 8px 11px;
|
|
372
|
+
border: 2px solid #7a7a95;
|
|
373
|
+
border-radius: 7px;
|
|
374
|
+
font-size: 14px;
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
color: #ffffff;
|
|
377
|
+
background: #55556a;
|
|
378
|
+
outline: none;
|
|
379
|
+
transition: border-color .15s, box-shadow .15s;
|
|
380
|
+
color-scheme: dark;
|
|
381
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
text-overflow: ellipsis;
|
|
384
|
+
}
|
|
385
|
+
select:hover { border-color: #9a9ab5; }
|
|
386
|
+
select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(124,109,250,.15); }
|
|
365
387
|
input[type="text"]:hover,
|
|
366
388
|
input[type="email"]:hover,
|
|
367
389
|
input[type="tel"]:hover,
|