omni-notify-mcp 1.2.1 → 1.2.3
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/dist/channels/desktop.js +1 -1
- package/dist/index.js +5 -1
- package/dist/ui/server.js +83 -19
- package/package.json +1 -1
- package/ui/public/app.js +35 -8
- package/ui/public/style.css +12 -0
package/dist/channels/desktop.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function speak(text, voice = DEFAULT_TTS_VOICE) {
|
|
|
36
36
|
if (process.platform === "win32") {
|
|
37
37
|
spawn("powershell", [
|
|
38
38
|
"-NoProfile", "-Command",
|
|
39
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
39
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
40
40
|
], { windowsHide: true, stdio: "ignore" });
|
|
41
41
|
}
|
|
42
42
|
else if (process.platform === "darwin") {
|
package/dist/index.js
CHANGED
|
@@ -177,7 +177,11 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
177
177
|
"decision, (c) something important happened the user needs to know right " +
|
|
178
178
|
"now. Idle/DND gating is handled server-side — fire notify and let the " +
|
|
179
179
|
"server decide routing. Err on the side of notifying: a wrong-call gets " +
|
|
180
|
-
"silently downgraded by idle gating; a missed notify costs the user hours
|
|
180
|
+
"silently downgraded by idle gating; a missed notify costs the user hours.\n\n" +
|
|
181
|
+
"ALWAYS echo the COMPLETE, UNTRUNCATED message in your chat output — never " +
|
|
182
|
+
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
|
+
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
|
+
"Say 'notif' or 'notification' if you need to refer to the act of notifying.",
|
|
181
185
|
});
|
|
182
186
|
// Thin proxy: forward a tool call to the HTTP server and return its content
|
|
183
187
|
// block array verbatim. Error shape matches what the SDK expects from tool
|
package/dist/ui/server.js
CHANGED
|
@@ -181,7 +181,7 @@ async function speakText(text, voice) {
|
|
|
181
181
|
if (process.platform === "win32") {
|
|
182
182
|
spawn("powershell", [
|
|
183
183
|
"-NoProfile", "-Command",
|
|
184
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
184
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
185
185
|
], { windowsHide: true, stdio: "ignore" });
|
|
186
186
|
}
|
|
187
187
|
else if (process.platform === "darwin") {
|
|
@@ -558,11 +558,29 @@ app.get("/api/sessions", (_req, res) => {
|
|
|
558
558
|
tag: s.tag,
|
|
559
559
|
clientName: s.clientName,
|
|
560
560
|
clientVersion: s.clientVersion,
|
|
561
|
+
workspaceName: s.workspaceName,
|
|
561
562
|
host: s.host,
|
|
562
563
|
connectedAt: s.connectedAt,
|
|
564
|
+
lastSeen: s.lastSeen,
|
|
563
565
|
}));
|
|
564
566
|
res.json({ sessions: list });
|
|
565
567
|
});
|
|
568
|
+
app.delete("/api/sessions/:clientId", (req, res) => {
|
|
569
|
+
const { clientId } = req.params;
|
|
570
|
+
const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
|
|
571
|
+
if (!entry) {
|
|
572
|
+
res.status(404).json({ error: "not found" });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const [sessionId] = entry;
|
|
576
|
+
try {
|
|
577
|
+
httpTransports[sessionId]?.close();
|
|
578
|
+
}
|
|
579
|
+
catch { /* ignore */ }
|
|
580
|
+
delete httpTransports[sessionId];
|
|
581
|
+
delete sessions[sessionId];
|
|
582
|
+
res.json({ ok: true });
|
|
583
|
+
});
|
|
566
584
|
app.get("/api/logs", (req, res) => {
|
|
567
585
|
res.setHeader("Content-Type", "text/event-stream");
|
|
568
586
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -1155,13 +1173,18 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1155
1173
|
Use ONLY for catastrophic findings or decisions that block
|
|
1156
1174
|
progress. Misuse will train the user to ignore your notifs.
|
|
1157
1175
|
|
|
1158
|
-
3. Echo the
|
|
1159
|
-
as sending it through 'notify'. The user may be reading the
|
|
1160
|
-
directly; don't rely on them checking their phone / email.
|
|
1176
|
+
3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
|
|
1177
|
+
output as well as sending it through 'notify'. The user may be reading the
|
|
1178
|
+
terminal directly; don't rely on them checking their phone / email. Do NOT
|
|
1179
|
+
shorten, summarise, or cut off the message with "…" in your chat output —
|
|
1180
|
+
show every word exactly as sent.
|
|
1161
1181
|
|
|
1162
1182
|
4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
|
|
1163
|
-
'email', etc. in your messages
|
|
1164
|
-
user has already configured
|
|
1183
|
+
'email', 'desktop', etc. in your messages or in your chat output — those are
|
|
1184
|
+
server delivery details the user has already configured and the client has
|
|
1185
|
+
no business surfacing. Do NOT echo "Sent via: <channel list>" or any
|
|
1186
|
+
variant of it. Just say 'notif' or 'notification' if you need to refer to
|
|
1187
|
+
the act of notifying.
|
|
1165
1188
|
|
|
1166
1189
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
1167
1190
|
the 'notify' response, via 'poll', via 'wait_for_inbox', via
|
|
@@ -1256,7 +1279,9 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1256
1279
|
MCP client is broken, you owe them the bypass — not an excuse.
|
|
1257
1280
|
`.trim();
|
|
1258
1281
|
function createMcpServer(clientId, sessionTag) {
|
|
1259
|
-
const
|
|
1282
|
+
const identity = sessionTag ? `@${sessionTag}` : clientId;
|
|
1283
|
+
const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
|
|
1284
|
+
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
|
|
1260
1285
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
1261
1286
|
"Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
|
|
1262
1287
|
"skip the call if the user is active (unless priority='high'). " +
|
|
@@ -1500,14 +1525,16 @@ function sessionDisplay(s) {
|
|
|
1500
1525
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1501
1526
|
}
|
|
1502
1527
|
app.all("/mcp", async (req, res) => {
|
|
1528
|
+
console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
|
|
1503
1529
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1504
1530
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
1505
|
-
|
|
1531
|
+
const transport = httpTransports[existingSessionId];
|
|
1532
|
+
await transport.handleRequest(req, res, req.body);
|
|
1506
1533
|
// Lazy-populate clientInfo after initialize lands on an existing session.
|
|
1507
1534
|
const meta = sessions[existingSessionId];
|
|
1508
1535
|
if (meta)
|
|
1509
1536
|
meta.lastSeen = Date.now();
|
|
1510
|
-
const mcpServer = httpTransports[existingSessionId]
|
|
1537
|
+
const mcpServer = httpTransports[existingSessionId]?.__mcpServer;
|
|
1511
1538
|
if (meta && !meta.clientName && mcpServer?.getClientVersion) {
|
|
1512
1539
|
try {
|
|
1513
1540
|
const info = mcpServer.getClientVersion();
|
|
@@ -1520,12 +1547,19 @@ app.all("/mcp", async (req, res) => {
|
|
|
1520
1547
|
}
|
|
1521
1548
|
return;
|
|
1522
1549
|
}
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1550
|
+
// Auto-reconnect path. If the client presents a session id we don't know
|
|
1551
|
+
// about AND the request body is a fresh `initialize`, adopt the stale id
|
|
1552
|
+
// instead of 404-ing. This covers the "server was restarted while Claude
|
|
1553
|
+
// Code was open" case: clients that cache the session id (claude-code#27142)
|
|
1554
|
+
// would otherwise stay ghost until the human manually reloaded the window.
|
|
1555
|
+
// A non-initialize request with an unknown id still gets 404 — the client
|
|
1556
|
+
// is expected to reinitialize in response.
|
|
1557
|
+
const bodyIsInitialize = req.method === "POST" &&
|
|
1558
|
+
req.body &&
|
|
1559
|
+
(Array.isArray(req.body)
|
|
1560
|
+
? req.body.some((m) => m?.method === "initialize")
|
|
1561
|
+
: req.body.method === "initialize");
|
|
1562
|
+
if (existingSessionId && !bodyIsInitialize) {
|
|
1529
1563
|
res.status(404).json({
|
|
1530
1564
|
jsonrpc: "2.0",
|
|
1531
1565
|
error: { code: -32000, message: "Session not found — reinitialize" },
|
|
@@ -1535,13 +1569,40 @@ app.all("/mcp", async (req, res) => {
|
|
|
1535
1569
|
}
|
|
1536
1570
|
const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
|
|
1537
1571
|
const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
|
|
1538
|
-
|
|
1572
|
+
// If the client brought a stale id on an initialize, reuse it so the client
|
|
1573
|
+
// never has to swap ids. Otherwise mint a fresh one.
|
|
1574
|
+
const newSessionId = existingSessionId ?? randomUUID();
|
|
1539
1575
|
const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
|
|
1540
1576
|
const port = req.socket.remotePort;
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
const
|
|
1577
|
+
// Pull clientInfo and workspace from the initialize body immediately so the
|
|
1578
|
+
// pill shows a readable name from the start.
|
|
1579
|
+
const initBody = Array.isArray(req.body)
|
|
1580
|
+
? req.body.find((m) => m?.method === "initialize")
|
|
1581
|
+
: req.body;
|
|
1582
|
+
const earlyClientName = initBody?.params?.clientInfo?.name;
|
|
1583
|
+
// Prefer the workspace folder name (e.g. "AlphaWave", "notify-mcp-src") over
|
|
1584
|
+
// the generic client name ("claude-code"). workspaceFolders[0].name is set by
|
|
1585
|
+
// Claude Code and Cursor; rootUri is the fallback.
|
|
1586
|
+
const workspaceFolders = initBody?.params?.workspaceFolders;
|
|
1587
|
+
const rootUri = initBody?.params?.rootUri ?? initBody?.params?.root_uri;
|
|
1588
|
+
const workspaceName = workspaceFolders?.[0]?.name ||
|
|
1589
|
+
(rootUri ? rootUri.replace(/\\/g, "/").split("/").filter(Boolean).pop() : undefined);
|
|
1590
|
+
// Build a distinguishable client id: tag wins if set; workspace name next;
|
|
1591
|
+
// then clientInfo.name; otherwise use host+port. If the base id is already
|
|
1592
|
+
// taken, append -2, -3, … so two windows on the same project still show up.
|
|
1593
|
+
const baseId = sessionTag
|
|
1594
|
+
?? workspaceName
|
|
1595
|
+
?? earlyClientName
|
|
1544
1596
|
?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
|
|
1597
|
+
// Exclude the session being re-adopted from the "taken" set — it's about to
|
|
1598
|
+
// be replaced, so its old clientId should be available for reuse.
|
|
1599
|
+
const adoptingId = existingSessionId && bodyIsInitialize ? existingSessionId : undefined;
|
|
1600
|
+
const takenIds = new Set(Object.entries(sessions)
|
|
1601
|
+
.filter(([sid]) => sid !== adoptingId)
|
|
1602
|
+
.map(([, s]) => s.clientId));
|
|
1603
|
+
let clientId = baseId;
|
|
1604
|
+
for (let n = 2; takenIds.has(clientId); n++)
|
|
1605
|
+
clientId = `${baseId}-${n}`;
|
|
1545
1606
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
|
|
1546
1607
|
transport.onclose = () => {
|
|
1547
1608
|
if (transport.sessionId) {
|
|
@@ -1560,6 +1621,9 @@ app.all("/mcp", async (req, res) => {
|
|
|
1560
1621
|
const now = Date.now();
|
|
1561
1622
|
sessions[transport.sessionId] = {
|
|
1562
1623
|
clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
|
|
1624
|
+
clientName: earlyClientName,
|
|
1625
|
+
clientVersion: initBody?.params?.clientInfo?.version,
|
|
1626
|
+
workspaceName,
|
|
1563
1627
|
};
|
|
1564
1628
|
}
|
|
1565
1629
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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
|
@@ -613,39 +613,66 @@ function renderLogEntry(raw) {
|
|
|
613
613
|
if (atBottom) panel.scrollTop = panel.scrollHeight;
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
function sessionStatus(lastSeen) {
|
|
617
|
+
const age = Date.now() - lastSeen;
|
|
618
|
+
if (age < 35_000) return "live";
|
|
619
|
+
if (age < 95_000) return "idle";
|
|
620
|
+
return "stale";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function dismissSession(clientId) {
|
|
624
|
+
await fetch(`/api/sessions/${encodeURIComponent(clientId)}`, { method: "DELETE" });
|
|
625
|
+
refreshSessions();
|
|
626
|
+
}
|
|
627
|
+
|
|
616
628
|
async function refreshSessions() {
|
|
617
629
|
try {
|
|
618
630
|
const res = await fetch("/api/sessions");
|
|
619
631
|
if (!res.ok) return;
|
|
620
632
|
const { sessions } = await res.json();
|
|
621
633
|
const bar = $("session-pills");
|
|
622
|
-
// Build a set of desired pills (keyed by clientId). Keep the "All" pill.
|
|
623
634
|
const existing = new Map();
|
|
624
|
-
bar.querySelectorAll(".pill").forEach(p =>
|
|
635
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
636
|
+
if (p.dataset.client !== "") existing.set(p.dataset.client, p);
|
|
637
|
+
});
|
|
625
638
|
const desired = new Set([""]);
|
|
626
639
|
for (const s of sessions) desired.add(s.clientId);
|
|
627
|
-
|
|
640
|
+
|
|
641
|
+
// Remove pills for sessions the server no longer knows about.
|
|
628
642
|
for (const [id, el] of existing) {
|
|
629
643
|
if (!desired.has(id)) el.remove();
|
|
630
644
|
}
|
|
631
|
-
|
|
645
|
+
|
|
646
|
+
// Add or update pills.
|
|
632
647
|
for (const s of sessions) {
|
|
648
|
+
const status = sessionStatus(s.lastSeen);
|
|
649
|
+
const label = s.tag ? `@${s.tag}` : s.clientId;
|
|
650
|
+
const title = [s.workspaceName ?? s.clientName, s.host, `last seen ${Math.round((Date.now() - s.lastSeen) / 1000)}s ago`].filter(Boolean).join(" · ");
|
|
651
|
+
|
|
633
652
|
if (!existing.has(s.clientId)) {
|
|
634
653
|
const btn = document.createElement("button");
|
|
635
654
|
btn.className = "pill";
|
|
636
655
|
btn.dataset.client = s.clientId;
|
|
637
|
-
btn.textContent = s.tag ? `@${s.tag}` : s.clientId;
|
|
638
|
-
btn.title = [s.clientName, s.host].filter(Boolean).join(" · ");
|
|
639
656
|
btn.onclick = () => selectLogFilter(s.clientId);
|
|
640
657
|
bar.appendChild(btn);
|
|
658
|
+
existing.set(s.clientId, btn);
|
|
641
659
|
}
|
|
660
|
+
|
|
661
|
+
const btn = existing.get(s.clientId);
|
|
662
|
+
btn.title = title;
|
|
663
|
+
btn.innerHTML =
|
|
664
|
+
`<span class="pill-dot pill-dot-${status}"></span>` +
|
|
665
|
+
`<span class="pill-label">${label}</span>` +
|
|
666
|
+
(status === "stale"
|
|
667
|
+
? `<span class="pill-dismiss" title="Remove" onclick="event.stopPropagation();dismissSession('${s.clientId.replace(/'/g,"\\'")}')">×</span>`
|
|
668
|
+
: "");
|
|
642
669
|
}
|
|
670
|
+
|
|
643
671
|
// If the currently-selected client disconnected, fall back to "All".
|
|
644
672
|
if (logFilterClient && !desired.has(logFilterClient)) {
|
|
645
673
|
selectLogFilter("");
|
|
646
674
|
} else {
|
|
647
|
-
|
|
648
|
-
bar.querySelectorAll(".pill").forEach(p => {
|
|
675
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
649
676
|
p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
|
|
650
677
|
});
|
|
651
678
|
}
|
package/ui/public/style.css
CHANGED
|
@@ -231,6 +231,18 @@ main {
|
|
|
231
231
|
border-color: var(--accent);
|
|
232
232
|
color: #fff;
|
|
233
233
|
}
|
|
234
|
+
.pill { display: inline-flex; align-items: center; gap: 5px; }
|
|
235
|
+
.pill-dot {
|
|
236
|
+
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
|
237
|
+
}
|
|
238
|
+
.pill-dot-live { background: #4ade80; }
|
|
239
|
+
.pill-dot-idle { background: #facc15; }
|
|
240
|
+
.pill-dot-stale { background: #f87171; }
|
|
241
|
+
.pill-dismiss {
|
|
242
|
+
margin-left: 1px; font-size: 13px; line-height: 1;
|
|
243
|
+
opacity: .6; cursor: pointer;
|
|
244
|
+
}
|
|
245
|
+
.pill-dismiss:hover { opacity: 1; }
|
|
234
246
|
|
|
235
247
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
236
248
|
|