omni-notify-mcp 1.3.8 → 1.3.12
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 +17 -9
- package/dist/index.js +71 -11
- package/dist/ui/messaging/notificationEngine.js +67 -0
- package/dist/ui/messaging/types.js +1 -0
- package/dist/ui/server.js +618 -223
- package/package.json +1 -1
- package/ui/public/help.html +6 -3
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<em>Reach me on any channel. Ask me anything. Get out of my way when I'm busy.</em><br>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
HTTP-first notification/control server with optional MCP compatibility for AI agents
|
|
10
|
+
(Claude, Copilot, Cursor, etc.): desktop, Telegram, Slack, SMS, email,
|
|
11
|
+
two-way replies, idle gating, and Do Not Disturb.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
@@ -37,6 +37,20 @@ You step away from your machine and the AI is still working. **It needs to**:
|
|
|
37
37
|
|
|
38
38
|
## Quick start
|
|
39
39
|
|
|
40
|
+
```bash
|
|
41
|
+
npx omni-notify-ui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This starts the HTTP/UI server on `http://localhost:3737`.
|
|
45
|
+
|
|
46
|
+
MCP is optional. To enable the `/mcp` endpoint, start with:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ENABLE_MCP=1 npx omni-notify-ui
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or use the stdio bridge (which auto-spawns the UI with MCP enabled):
|
|
53
|
+
|
|
40
54
|
```bash
|
|
41
55
|
npx omni-notify-mcp
|
|
42
56
|
```
|
|
@@ -54,12 +68,6 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
|
|
|
54
68
|
}
|
|
55
69
|
```
|
|
56
70
|
|
|
57
|
-
Then run the config UI to wire up your channels:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
npx omni-notify-ui
|
|
61
|
-
```
|
|
62
|
-
|
|
63
71
|
Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
|
|
64
72
|
|
|
65
73
|
## What the agent gets
|
package/dist/index.js
CHANGED
|
@@ -27,12 +27,40 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
27
27
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
28
|
import { spawn } from "child_process";
|
|
29
29
|
import { existsSync } from "fs";
|
|
30
|
-
import { join, dirname } from "path";
|
|
30
|
+
import { join, dirname, basename } from "path";
|
|
31
|
+
import { hostname } from "os";
|
|
31
32
|
import { fileURLToPath } from "url";
|
|
32
33
|
import { z } from "zod";
|
|
33
34
|
const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
|
|
34
35
|
const BASE = `http://localhost:${PORT}`;
|
|
35
|
-
const
|
|
36
|
+
const CLEAN_ID = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
37
|
+
// NOTIFY_MCP_TAG is the explicit per-window name; otherwise use the nearest
|
|
38
|
+
// meaningful working-dir folder, skipping generic launcher/tool/system dirs so a
|
|
39
|
+
// bridge spawned from an editor's launch dir names itself after the project
|
|
40
|
+
// (e.g. "bullseyenotify"), never the host ("claude-code").
|
|
41
|
+
function deriveVscId() {
|
|
42
|
+
const explicit = CLEAN_ID(process.env.NOTIFY_MCP_TAG ?? "");
|
|
43
|
+
if (explicit)
|
|
44
|
+
return explicit;
|
|
45
|
+
const generic = new Set([
|
|
46
|
+
"claude-code", "claude", "code", "cursor", "vscode", "windsurf",
|
|
47
|
+
"bin", "dist", "build", "src", "out", "node_modules",
|
|
48
|
+
"windows", "system32", "users", "appdata", "roaming", "local", "programs", "temp", "tmp",
|
|
49
|
+
]);
|
|
50
|
+
let dir = process.cwd();
|
|
51
|
+
for (let i = 0; i < 5; i++) {
|
|
52
|
+
const name = CLEAN_ID(basename(dir));
|
|
53
|
+
if (name && !generic.has(name))
|
|
54
|
+
return name;
|
|
55
|
+
const parent = dirname(dir);
|
|
56
|
+
if (!parent || parent === dir)
|
|
57
|
+
break;
|
|
58
|
+
dir = parent;
|
|
59
|
+
}
|
|
60
|
+
return CLEAN_ID(basename(process.cwd())) || "agent";
|
|
61
|
+
}
|
|
62
|
+
const VSC_ID = deriveVscId();
|
|
63
|
+
const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}`;
|
|
36
64
|
const CLIENT_NAME = "claude-channel-bridge";
|
|
37
65
|
// ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
|
|
38
66
|
async function serverIsUp() {
|
|
@@ -43,9 +71,9 @@ async function serverIsUp() {
|
|
|
43
71
|
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
|
|
44
72
|
signal: AbortSignal.timeout(1500),
|
|
45
73
|
});
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
return r.status > 0;
|
|
74
|
+
// The bridge requires an active /mcp endpoint.
|
|
75
|
+
// 404 specifically means UI may be up but MCP transport is disabled.
|
|
76
|
+
return r.status > 0 && r.status !== 404;
|
|
49
77
|
}
|
|
50
78
|
catch {
|
|
51
79
|
return false;
|
|
@@ -68,7 +96,7 @@ function spawnUiServerIfNeeded() {
|
|
|
68
96
|
const child = spawn(process.execPath, [uiPath], {
|
|
69
97
|
detached: true,
|
|
70
98
|
stdio: "ignore",
|
|
71
|
-
env: { ...process.env, PORT: String(PORT) },
|
|
99
|
+
env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
|
|
72
100
|
});
|
|
73
101
|
child.unref();
|
|
74
102
|
}
|
|
@@ -159,6 +187,21 @@ async function httpInitialize() {
|
|
|
159
187
|
// Follow-up: the spec requires a notifications/initialized after initialize.
|
|
160
188
|
await httpRpc("notifications/initialized").catch(() => { });
|
|
161
189
|
}
|
|
190
|
+
// Periodically touch our /mcp session so the server's reaper doesn't prune it.
|
|
191
|
+
// Without this, the bridge opens a session on startup, proxies tool calls
|
|
192
|
+
// infrequently, and after 60s of idle the server wipes the session from its
|
|
193
|
+
// sessions[] map — which makes Telegram's "routed to X agents" ack say
|
|
194
|
+
// "no agents connected" even though the bridge is still alive and subscribed.
|
|
195
|
+
function startSessionKeepalive() {
|
|
196
|
+
setInterval(async () => {
|
|
197
|
+
try {
|
|
198
|
+
await httpRpc("tools/list").catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// best effort; next call will re-initialize via 404 retry path
|
|
202
|
+
}
|
|
203
|
+
}, 30_000);
|
|
204
|
+
}
|
|
162
205
|
// ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
|
|
163
206
|
const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
164
207
|
// Declare the claude/channel capability so Claude Code knows to surface
|
|
@@ -168,10 +211,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
168
211
|
capabilities: {
|
|
169
212
|
experimental: { "claude/channel": {} },
|
|
170
213
|
},
|
|
171
|
-
instructions: "This is the stdio bridge for notify-mcp.
|
|
172
|
-
"messages
|
|
173
|
-
"
|
|
174
|
-
"
|
|
214
|
+
instructions: "This is the stdio bridge for notify-mcp. To receive unsolicited user " +
|
|
215
|
+
"messages in ANY MCP host (Claude Code, Copilot, Cursor), call `wait_for_inbox` " +
|
|
216
|
+
"— it long-polls up to 50s and returns new user messages as tool results. Call " +
|
|
217
|
+
"it whenever you finish a task or would otherwise wait, then again right after " +
|
|
218
|
+
"handling each batch, so messages keep arriving. (The Claude Code CLI launched " +
|
|
219
|
+
"with `--channels` ALSO delivers messages as synthetic turns automatically, but " +
|
|
220
|
+
"that path does NOT exist in the VSCode extension or Copilot — `wait_for_inbox` " +
|
|
221
|
+
"is the reliable lowest-common-denominator mechanism across every host.)\n\n" +
|
|
175
222
|
"ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
|
|
176
223
|
"just finished (success or failure), (b) you have a question or need a " +
|
|
177
224
|
"decision, (c) something important happened the user needs to know right " +
|
|
@@ -182,6 +229,15 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
182
229
|
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
230
|
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
231
|
"Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
|
|
232
|
+
"🚨 500-CHAR LIMIT — CHUNK, NEVER TRUNCATE 🚨\n" +
|
|
233
|
+
"The `notify` tool rejects bodies > 500 chars with `MCP error -32602: too_big`. " +
|
|
234
|
+
"When you have more to say than fits in 500 chars, you MUST split into MULTIPLE " +
|
|
235
|
+
"notify calls — do NOT silently shorten the body. Procedure: (1) decide what " +
|
|
236
|
+
"the user MUST see (every fact, file path, line number, recommendation); (2) if " +
|
|
237
|
+
"the body exceeds 500 chars, split into N chunks numbered '(1/N) ...', '(2/N) ...', " +
|
|
238
|
+
"each ≤ 500 chars including the prefix; (3) send all N chunks in order via separate " +
|
|
239
|
+
"notify calls and echo all N IN FULL in chat. NEVER respond to a `too_big` error by " +
|
|
240
|
+
"shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
|
|
185
241
|
"When the user asks you to remember a behavioral rule or change how you should act, " +
|
|
186
242
|
"call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
|
|
187
243
|
"so the instructions persist across sessions and context compaction.",
|
|
@@ -200,7 +256,10 @@ async function proxyToolCall(name, args) {
|
|
|
200
256
|
}
|
|
201
257
|
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
202
258
|
}
|
|
203
|
-
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured."
|
|
259
|
+
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
260
|
+
"MAX 500 CHARS PER MESSAGE. If you have more to say, split into multiple notify " +
|
|
261
|
+
"calls with '(1/N) ...', '(2/N) ...' prefixes — never silently shorten on " +
|
|
262
|
+
"`too_big` error; that loses information the user needs.", {
|
|
204
263
|
message: z.string().max(500),
|
|
205
264
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
206
265
|
}, async (args) => proxyToolCall("notify", args));
|
|
@@ -323,6 +382,7 @@ async function main() {
|
|
|
323
382
|
// Fire-and-forget: the stdio transport should be usable immediately; the
|
|
324
383
|
// push channel attaches as soon as the SSE handshake completes.
|
|
325
384
|
subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
|
|
385
|
+
startSessionKeepalive();
|
|
326
386
|
const transport = new StdioServerTransport();
|
|
327
387
|
await server.connect(transport);
|
|
328
388
|
stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function computeDesktopOnlyMode(priority, policy, ctx) {
|
|
2
|
+
if (priority === "high") {
|
|
3
|
+
return { desktopOnly: false };
|
|
4
|
+
}
|
|
5
|
+
if (policy.dndActive) {
|
|
6
|
+
return { desktopOnly: false, suppressedReason: "dnd" };
|
|
7
|
+
}
|
|
8
|
+
if (ctx.uiActive || (policy.idleEnabled && !ctx.inTelegramConversation && ctx.idleSeconds >= 0 && ctx.idleSeconds < policy.idleThresholdSeconds)) {
|
|
9
|
+
if (policy.alwaysDesktopWhenActive) {
|
|
10
|
+
return { desktopOnly: true };
|
|
11
|
+
}
|
|
12
|
+
return { desktopOnly: false, suppressedReason: "idle" };
|
|
13
|
+
}
|
|
14
|
+
return { desktopOnly: false };
|
|
15
|
+
}
|
|
16
|
+
export async function sendWithRouting(options) {
|
|
17
|
+
const { message, priority, senders, policy, ctx, enableDesktop, enableTelegram, enableEmail, enableSms, enableNtfy, enableDiscord, enableSlack, enableTeams, } = options;
|
|
18
|
+
const mode = computeDesktopOnlyMode(priority, policy, ctx);
|
|
19
|
+
if (mode.suppressedReason === "dnd") {
|
|
20
|
+
return { delivered: [], errors: [], suppressedReason: "DND active" };
|
|
21
|
+
}
|
|
22
|
+
if (mode.suppressedReason === "idle") {
|
|
23
|
+
return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
|
|
24
|
+
}
|
|
25
|
+
const delivered = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
const desktopOnly = mode.desktopOnly;
|
|
28
|
+
const trySend = async (name, fn) => {
|
|
29
|
+
if (!fn)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
await fn();
|
|
33
|
+
delivered.push(name);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
errors.push(`${name}: ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if (priority !== "low") {
|
|
41
|
+
if (enableDesktop) {
|
|
42
|
+
await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
|
|
43
|
+
}
|
|
44
|
+
if (!desktopOnly && enableTelegram) {
|
|
45
|
+
await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!desktopOnly && enableEmail) {
|
|
49
|
+
await trySend("email", senders.email ? () => senders.email(message) : undefined);
|
|
50
|
+
}
|
|
51
|
+
if (!desktopOnly && enableNtfy) {
|
|
52
|
+
await trySend("ntfy", senders.ntfy ? () => senders.ntfy(message, priority) : undefined);
|
|
53
|
+
}
|
|
54
|
+
if (!desktopOnly && enableDiscord) {
|
|
55
|
+
await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
|
|
56
|
+
}
|
|
57
|
+
if (!desktopOnly && enableSlack) {
|
|
58
|
+
await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
|
|
59
|
+
}
|
|
60
|
+
if (!desktopOnly && enableTeams) {
|
|
61
|
+
await trySend("teams", senders.teams ? () => senders.teams(message, priority) : undefined);
|
|
62
|
+
}
|
|
63
|
+
if (priority === "high" && enableSms) {
|
|
64
|
+
await trySend("sms", senders.sms ? () => senders.sms(message) : undefined);
|
|
65
|
+
}
|
|
66
|
+
return { delivered, errors };
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|