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 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** (natural neural voice via `msedge-tts`, no API key).
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 (_req, res) => {
193
+ app.post("/api/test/tts", async (req, res) => {
194
194
  try {
195
195
  const cfg = loadConfig();
196
- const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
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.5",
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 && tg.token && tg.chatId ? "ok" : tg.token ? "warn" : "idle",
120
- tg.enabled && tg.token && tg.chatId ? "Configured" : tg.token ? "Incomplete" : "Not configured");
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 && sms.accountSid && sms.authToken ? "ok" : sms.accountSid ? "warn" : "idle",
125
- sms.enabled && sms.accountSid && sms.authToken ? "Configured" : sms.accountSid ? "Incomplete" : "Not configured");
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 res = await fetch("/api/test/tts", { method: "POST" });
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");
@@ -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>
@@ -102,11 +102,11 @@ main {
102
102
  flex: 1;
103
103
  min-height: 0;
104
104
  height: 100%;
105
- overflow: hidden;
106
- display: flex;
107
- flex-direction: column;
108
- flex-wrap: wrap;
109
- align-content: flex-start;
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: 280px;
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,