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 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
- An MCP server that gives AI agents (Claude, Cursor, etc.) a single
10
- <code>notify</code> / <code>ask</code> interface desktop, Telegram, SMS, email
11
- with two-way replies, idle gating, and Do Not Disturb.
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 SESSION_TAG = (process.env.NOTIFY_MCP_TAG ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
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
- // Any HTTP response (including 400/406 from malformed init) means the
47
- // server is alive and speaking HTTP. Real "down" surfaces as a throw.
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. It pushes unsolicited user " +
172
- "messages to the agent via `notifications/claude/channel` when the host " +
173
- "supports Channels (Claude Code v2.1.80+). Otherwise call `wait_for_inbox` " +
174
- "as a long-poll to reliably receive user messages as tool results.\n\n" +
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 {};