omni-notify-mcp 1.3.14 → 1.3.21

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/index.js CHANGED
@@ -241,35 +241,41 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
241
241
  capabilities: {
242
242
  experimental: { "claude/channel": {} },
243
243
  },
244
- instructions: "This is the stdio bridge for notify-mcp. To receive unsolicited user " +
245
- "messages in ANY MCP host (Claude Code, Copilot, Cursor), call `wait_for_inbox` " +
246
- "— it long-polls up to 50s and returns new user messages as tool results. Call " +
247
- "it whenever you finish a task or would otherwise wait, then again right after " +
248
- "handling each batch, so messages keep arriving. (The Claude Code CLI launched " +
249
- "with `--channels` ALSO delivers messages as synthetic turns automatically, but " +
250
- "that path does NOT exist in the VSCode extension or Copilot — `wait_for_inbox` " +
251
- "is the reliable lowest-common-denominator mechanism across every host.)\n\n" +
252
- "ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
253
- "just finished (success or failure), (b) you have a question or need a " +
254
- "decision, (c) something important happened the user needs to know right " +
255
- "now. Idle/DND gating is handled server-side — fire notify and let the " +
256
- "server decide routing. Err on the side of notifying: a wrong-call gets " +
257
- "silently downgraded by idle gating; a missed notify costs the user hours.\n\n" +
258
- "ALWAYS echo the COMPLETE, UNTRUNCATED message in your chat output — never " +
259
- "shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
260
- "SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
261
- "Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
262
- "🚨 NEVER TRUNCATE — THE SERVER CHUNKS FOR YOU 🚨\n" +
263
- "Send the COMPLETE message (up to 5000 chars) in a single `notify` call. Bodies over " +
264
- "500 chars are automatically split server-side into numbered '(1/N) ...', '(2/N) ...' " +
265
- "chunks (each ≤500 chars) and delivered in order — you no longer hand-split, and a " +
266
- "`too_big` error only fires past the 5000-char cap. Decide what the user MUST see " +
267
- "(every fact, file path, line number, recommendation), send it all in one call, and " +
268
- "echo it IN FULL in chat. NEVER shorten or summarize to fit — that loses information " +
269
- "the user explicitly needs.\n\n" +
270
- "When the user asks you to remember a behavioral rule or change how you should act, " +
271
- "call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
272
- "so the instructions persist across sessions and context compaction.",
244
+ instructions: "This server is how you reach the user — and only you can decide to use it: nothing " +
245
+ "here delivers a message the user never asked for unless you or your own loop calls a " +
246
+ "tool. Four tools hold that conversation over whatever channels the server has " +
247
+ "configured: `notify` sends the user a message; `ask` sends a question and waits for " +
248
+ "their answer; `wait_for_inbox` waits up to 50s and returns the messages the user sent " +
249
+ "you; `reply` answers the user's most recent message back over the channel they are " +
250
+ "reading. Call `wait_for_inbox` whenever you finish a task or would otherwise be " +
251
+ "waiting, then call it again right after handling anything it returns.\n\n" +
252
+ "ALWAYS call `notify` when: (a) a task that took more than about a minute of wall-clock " +
253
+ "time just finished, success or failure; (b) you have a question or need a decision; " +
254
+ "(c) something important happened the user needs to know now. Never pre-check whether " +
255
+ "the user is at their desk — the server applies Do Not Disturb and idle gating itself, " +
256
+ "so fire `notify` and let the server decide routing. Fire it even when you are unsure: " +
257
+ "an unwanted notification the server holds back costs nothing, while a missed one " +
258
+ "costs the user hours.\n\n" +
259
+ "Read the result of every `notify` call. `outcome=delivered` means at least one channel " +
260
+ "carried it. `outcome=failed` or `outcome=blocked` means it reached nobody — never " +
261
+ "assume the user was told: say so in your reply and tell them no notification channel " +
262
+ "is configured.\n\n" +
263
+ "Echo the COMPLETE, UNTRUNCATED message in your chat output as well — never shorten it " +
264
+ "with '…' or a summary. Never name a delivery channel (Telegram, SMS, desktop, etc.) — " +
265
+ "those are server internals. Say 'notif' or 'notification' to refer to the act of " +
266
+ "notifying.\n\n" +
267
+ "NEVER TRUNCATE — THE SERVER CHUNKS FOR YOU.\n" +
268
+ "The hard limit is 500 characters per message, and the server enforces it by splitting " +
269
+ "for you: send the COMPLETE message (up to 5000 characters) in a single `notify` call " +
270
+ "and any body over 500 characters is automatically split into numbered parts — " +
271
+ "'(1/N) ...', '(2/N) ...' — each 500 characters or less and delivered in order, so you " +
272
+ "never hand-split and never lose the tail. A `too_big` error means you exceeded the " +
273
+ "5000-character cap; it never means you should shorten the message. Decide everything " +
274
+ "the user must see — every fact, file path, line number, recommendation — send it all " +
275
+ "in one call, and echo it in full in chat.\n\n" +
276
+ "When the user asks you to remember a rule or change how you should act, call " +
277
+ "`update_instructions` with the full updated rules block: it replaces the previous " +
278
+ "block atomically so the rule survives session restarts and context compaction.",
273
279
  });
274
280
  // Thin proxy: forward a tool call to the HTTP server and return its content
275
281
  // block array verbatim. Error shape matches what the SDK expects from tool
@@ -302,7 +308,11 @@ async function sendNotifyChunked(message, priority) {
302
308
  .map(t => { const i = t.indexOf(inboxMarker); return i === -1 ? null : t.slice(i); })
303
309
  .filter((b) => b !== null);
304
310
  const inboxSuffix = inboxBlocks.length ? `\n\n${inboxBlocks.join("\n\n")}` : "";
305
- const delivered = texts.filter(t => t.includes("Sent via:")).length;
311
+ // Structural, never a string match: the server answers with an explicit
312
+ // `outcome=` token, so a send that reached nobody cannot read as a delivery
313
+ // merely because it failed to match some happy-path phrase.
314
+ const outcomeOf = (t) => (t.match(/outcome=(\w+)/)?.[1] ?? "unknown");
315
+ const delivered = texts.filter(t => outcomeOf(t) === "delivered").length;
306
316
  const n = chunks.length;
307
317
  let summary;
308
318
  if (delivered === n) {
@@ -310,18 +320,27 @@ async function sendNotifyChunked(message, priority) {
310
320
  }
311
321
  else {
312
322
  const stripInbox = (t) => { const i = t.indexOf(inboxMarker); return (i === -1 ? t : t.slice(0, i)).trim(); };
313
- const failedSummaries = [...new Set(texts.filter(t => !t.includes("Sent via:")).map(stripInbox))];
323
+ const failedSummaries = [...new Set(texts.filter(t => outcomeOf(t) !== "delivered").map(stripInbox))];
314
324
  const said = failedSummaries.map(s => `"${s}"`).join(", ");
315
325
  summary = delivered === 0
316
- ? `Suppressed — 0 of ${n} chunks reached any channel. Server said: ${said}`
326
+ ? `NOT DELIVERED — 0 of ${n} chunks reached any channel. Server said: ${said}`
317
327
  : `Delivered ${delivered}/${n} chunks (${message.length} chars); ${n - delivered} reached no channel — server said: ${said}.`;
318
328
  }
329
+ // A chunk that reached nobody is an ERROR result, not a report the caller
330
+ // reads past: an escalation that silently went nowhere is the whole defect.
331
+ if (delivered < n) {
332
+ return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }], isError: true };
333
+ }
319
334
  return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }] };
320
335
  }
321
336
  server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
322
- "Messages over 500 chars are automatically split into numbered '(1/N) ...' chunks " +
323
- "(each ≤500 chars) and delivered in order — send the full message in one call, never " +
324
- "truncate. Hard cap 5000 chars.", {
337
+ "The result always carries an explicit outcome: `outcome=delivered` means at least one " +
338
+ "channel carried the message, while `outcome=failed` or `outcome=blocked` means it " +
339
+ "reached NOBODY — never assume the user was told. " +
340
+ "One message is limited to 500 characters; the 5000-character cap on this tool is only " +
341
+ "the outer bound. Send the COMPLETE message in one call — anything over 500 characters " +
342
+ "is split for you into numbered '(1/N) ...' parts, each 500 characters or less, and " +
343
+ "delivered in order. Never truncate to fit a part.", {
325
344
  message: z.string().max(5000),
326
345
  priority: z.enum(["low", "normal", "high"]).default("normal"),
327
346
  }, async ({ message, priority }) => sendNotifyChunked(message, priority));
@@ -334,7 +353,12 @@ server.tool("wait_for_inbox", "Block until an unsolicited user message arrives o
334
353
  "delivery across every MCP client (messages come back as tool results).", {
335
354
  timeout_seconds: z.number().min(5).max(55).default(50),
336
355
  }, async (args) => proxyToolCall("wait_for_inbox", args));
337
- server.tool("get_idle_seconds", "Seconds since user's last keyboard/mouse input. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_seconds", {}));
356
+ server.tool("get_idle_seconds", "Periodic heartbeat: how long since the user's last keyboard/mouse input, and drains " +
357
+ "the inbox as a side effect. Two distinct answers — 'idle: measured N seconds since " +
358
+ "the last keyboard/mouse input' is a reading; 'idle: could not measure' means this " +
359
+ "machine cannot report user activity at all, in which case gating is skipped and the " +
360
+ "server delivers. Neither is a number for you to compare against a threshold; the " +
361
+ "server gates internally.", {}, async () => proxyToolCall("get_idle_seconds", {}));
338
362
  server.tool("get_idle_config", "Server's idle gating policy. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_config", {}));
339
363
  server.tool("get_dnd_status", "Current DND state. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_dnd_status", {}));
340
364
  server.tool("update_instructions", "Persist behavioral instructions for this client into CLAUDE.md so they survive " +
@@ -349,7 +373,8 @@ server.tool("update_instructions", "Persist behavioral instructions for this cli
349
373
  // it straight through `notify` so it flows to whatever channel the user is
350
374
  // actually reading (Telegram, desktop, email, ...).
351
375
  server.tool("reply", "Reply to the user's most recent channel message. Routes through notify so " +
352
- "the response reaches whichever channel the user is reading.", {
376
+ "the response reaches whichever channel the user is reading. The result " +
377
+ "carries the same `outcome=` contract as notify — an error means it reached nobody.", {
353
378
  message: z.string().max(2000).describe("The reply text to deliver"),
354
379
  priority: z.enum(["low", "normal", "high"]).default("normal"),
355
380
  }, async ({ message, priority }) => {
@@ -1,65 +1,190 @@
1
+ // A failed measurement is a NAMED state, never a number. `get_idle_seconds`
2
+ // answers -1 when it could not read, and -1 sits in-band with every real
3
+ // threshold comparison (a negative is always "under" the threshold), so a
4
+ // failed read and a genuine reading are indistinguishable once it is a number.
5
+ // Converting at the producer keeps the distinction for every consumer.
6
+ export function idleReading(seconds) {
7
+ return seconds >= 0 ? { measurable: true, seconds } : { measurable: false };
8
+ }
9
+ export function formatIdleReading(reading) {
10
+ return reading.measurable
11
+ ? `idle: measured ${reading.seconds} seconds since the last keyboard/mouse input`
12
+ : "idle: could not measure — user activity unknown, so activity gating is skipped";
13
+ }
14
+ const EVERY_PRIORITY = ["low", "normal", "high"];
15
+ // GATE: which priorities reach each channel, in send order, and what an at-keyboard user holds back — a channel is never attempted for a priority outside its list
16
+ const CHANNEL_ROUTES = [
17
+ { channel: "desktop", priorities: ["normal", "high"], atKeyboard: "desktopSignal" },
18
+ { channel: "telegram", priorities: ["normal", "high"], atKeyboard: "heldBack" },
19
+ { channel: "email", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
20
+ { channel: "ntfy", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
21
+ { channel: "discord", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
22
+ { channel: "slack", priorities: EVERY_PRIORITY, atKeyboard: "channelLog" },
23
+ { channel: "teams", priorities: EVERY_PRIORITY, atKeyboard: "heldBack" },
24
+ { channel: "sms", priorities: ["high"], atKeyboard: "heldBack" },
25
+ ];
26
+ export function describePriorityRouting() {
27
+ return EVERY_PRIORITY
28
+ .map((priority) => `${priority}=${CHANNEL_ROUTES.filter((route) => route.priorities.includes(priority)).map((route) => route.channel).join("+")}`)
29
+ .join("; ");
30
+ }
1
31
  export function computeDesktopOnlyMode(priority, policy, ctx) {
32
+ const idleKnown = ctx.idle.measurable;
2
33
  if (priority === "high") {
3
- return { desktopOnly: false };
34
+ return {
35
+ desktopOnly: false,
36
+ idle: ctx.idle,
37
+ idleKnown,
38
+ reason: "priority=high — every enabled channel is used and activity gating is waived",
39
+ };
4
40
  }
5
41
  if (policy.dndActive) {
6
- return { desktopOnly: false, suppressedReason: "dnd" };
42
+ return {
43
+ desktopOnly: false,
44
+ suppressedReason: "dnd",
45
+ idle: ctx.idle,
46
+ idleKnown,
47
+ reason: "DND active — no channel was attempted",
48
+ };
49
+ }
50
+ // The measurement FAILED: a deliberate fail-open. Stated as its own branch so
51
+ // it can never be reached by accident of a comparison against a sentinel.
52
+ if (policy.idleEnabled && !ctx.inTelegramConversation && !ctx.idle.measurable) {
53
+ return {
54
+ desktopOnly: false,
55
+ idle: ctx.idle,
56
+ idleKnown,
57
+ reason: "user activity could not be measured — gating skipped and every enabled channel is used",
58
+ };
7
59
  }
8
- if (ctx.uiActive || (policy.idleEnabled && !ctx.inTelegramConversation && ctx.idleSeconds >= 0 && ctx.idleSeconds < policy.idleThresholdSeconds)) {
60
+ const idleGated = policy.idleEnabled
61
+ && !ctx.inTelegramConversation
62
+ && ctx.idle.measurable
63
+ && ctx.idle.seconds < policy.idleThresholdSeconds;
64
+ if (ctx.uiActive || idleGated) {
65
+ const why = ctx.uiActive
66
+ ? "the UI is open"
67
+ : `idle ${ctx.idle.measurable ? ctx.idle.seconds : 0}s is under the ${policy.idleThresholdSeconds}s threshold`;
9
68
  if (policy.alwaysDesktopWhenActive) {
10
- return { desktopOnly: true };
69
+ return {
70
+ desktopOnly: true,
71
+ idle: ctx.idle,
72
+ idleKnown,
73
+ reason: `${why} — desktop only, remote channels skipped`,
74
+ };
11
75
  }
12
- return { desktopOnly: false, suppressedReason: "idle" };
76
+ return {
77
+ desktopOnly: false,
78
+ suppressedReason: "idle",
79
+ idle: ctx.idle,
80
+ idleKnown,
81
+ reason: `${why} and alwaysDesktopWhenActive is false — no desktop signal, only the Slack channel log is used`,
82
+ };
13
83
  }
14
- return { desktopOnly: false };
84
+ return {
85
+ desktopOnly: false,
86
+ idle: ctx.idle,
87
+ idleKnown,
88
+ reason: "the user is considered away — every enabled channel is used",
89
+ };
90
+ }
91
+ function reachWhileAtKeyboard(decision) {
92
+ if (decision.suppressedReason === "idle")
93
+ return ["channelLog"];
94
+ if (decision.desktopOnly)
95
+ return ["desktopSignal", "channelLog"];
96
+ return ["heldBack", "desktopSignal", "channelLog"];
15
97
  }
16
98
  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" };
99
+ const { message, priority, senders, policy, ctx } = options;
100
+ const enabled = {
101
+ desktop: options.enableDesktop,
102
+ telegram: options.enableTelegram,
103
+ email: options.enableEmail,
104
+ ntfy: options.enableNtfy,
105
+ discord: options.enableDiscord,
106
+ slack: options.enableSlack,
107
+ teams: options.enableTeams,
108
+ sms: options.enableSms,
109
+ };
110
+ const decision = computeDesktopOnlyMode(priority, policy, ctx);
111
+ if (decision.suppressedReason === "dnd") {
112
+ return {
113
+ delivered: [],
114
+ attempted: [],
115
+ errors: [],
116
+ suppressedReason: "DND active",
117
+ outcome: "suppressed",
118
+ summary: `Suppressed — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
119
+ };
21
120
  }
22
121
  const delivered = [];
23
122
  const errors = [];
24
- // Idle gating downgrades to desktop-only; Slack (vsc_notif channel-log) is exempt below.
25
- const desktopOnly = mode.desktopOnly || mode.suppressedReason === "idle";
26
- const trySend = async (name, fn) => {
27
- if (!fn)
28
- return;
123
+ const attempted = [];
124
+ const reach = reachWhileAtKeyboard(decision);
125
+ for (const route of CHANNEL_ROUTES) {
126
+ if (!enabled[route.channel] || !route.priorities.includes(priority) || !reach.includes(route.atKeyboard))
127
+ continue;
128
+ attempted.push(route.channel);
129
+ // OWNER: ITER/LOOP — one channel's provider failing must not stop the remaining channels — its reason is recorded per channel and the outcome below reports failed when no channel delivered
29
130
  try {
30
- await fn();
31
- delivered.push(name);
131
+ await senders[route.channel](message, priority);
132
+ delivered.push(route.channel);
32
133
  }
33
134
  catch (err) {
34
- const msg = err instanceof Error ? err.message : String(err);
35
- errors.push(`${name}: ${msg}`);
135
+ errors.push(`${route.channel}: ${err instanceof Error ? err.message : String(err)}`);
36
136
  }
37
- };
38
- if (priority !== "low") {
39
- if (enableDesktop) {
40
- await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
41
- }
42
- if (!desktopOnly && enableTelegram) {
43
- await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
44
- }
45
- }
46
- if (!desktopOnly && enableEmail) {
47
- await trySend("email", senders.email ? () => senders.email(message) : undefined);
48
137
  }
49
- if (!desktopOnly && enableNtfy) {
50
- await trySend("ntfy", senders.ntfy ? () => senders.ntfy(message, priority) : undefined);
138
+ // A send that reached nobody is its own outcome — never a value the caller
139
+ // has to compare against a happy-path string to notice.
140
+ if (delivered.length > 0) {
141
+ return {
142
+ delivered,
143
+ attempted,
144
+ errors,
145
+ outcome: "delivered",
146
+ summary: `Delivered to ${delivered.length} channel${delivered.length === 1 ? "" : "s"}: ${delivered.join(", ")} — ${decision.reason}.`,
147
+ };
51
148
  }
52
- if (!desktopOnly && enableDiscord) {
53
- await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
149
+ if (decision.suppressedReason === "idle" && attempted.length === 0) {
150
+ return {
151
+ delivered,
152
+ attempted,
153
+ errors,
154
+ suppressedReason: "idle",
155
+ outcome: "suppressed",
156
+ summary: `Suppressed — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
157
+ };
54
158
  }
55
- if (enableSlack) {
56
- await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
159
+ // Nothing was even eligible to try — this is NOT a hold (the branches above
160
+ // already caught dnd/idle) and it is NOT a per-channel send error (nothing
161
+ // was attempted, so `errors` is necessarily empty). It is either "nothing is
162
+ // turned on at all" or "something is turned on but doesn't reach this
163
+ // priority/activity state" — story #438: the old text here ("reached 0 of 0
164
+ // enabled channels; the server log names which send failed") actively lied
165
+ // in this branch, since nothing was ever attempted and no log line exists to
166
+ // name. Naming what IS enabled (or that nothing is) is the whole difference
167
+ // between an agent that can act on the failure and one that cannot.
168
+ if (attempted.length === 0) {
169
+ const enabledChannels = Object.keys(enabled).filter((c) => enabled[c]);
170
+ const configHint = enabledChannels.length === 0
171
+ ? "no channel is enabled at all — enable at least one (desktop, telegram, email, ntfy, discord, slack, teams, sms) in the settings UI before calling notify again"
172
+ : `${enabledChannels.join(", ")} ${enabledChannels.length === 1 ? "is" : "are"} enabled but none of them reaches priority=${priority} while ${reach.join("/")}-class channels are reachable — check each channel's priority range and activity gating`;
173
+ return {
174
+ delivered,
175
+ attempted,
176
+ errors,
177
+ outcome: "failed",
178
+ summary: `NOT DELIVERED — no channel was eligible to try: ${configHint} — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
179
+ };
57
180
  }
58
- if (!desktopOnly && enableTeams) {
59
- await trySend("teams", senders.teams ? () => senders.teams(message, priority) : undefined);
60
- }
61
- if (priority === "high" && enableSms) {
62
- await trySend("sms", senders.sms ? () => senders.sms(message) : undefined);
63
- }
64
- return { delivered, errors };
181
+ return {
182
+ delivered,
183
+ attempted,
184
+ errors,
185
+ outcome: "failed",
186
+ summary: `NOT DELIVERED — reached 0 of ${attempted.length} enabled channel${attempted.length === 1 ? "" : "s"}`
187
+ + `; all ${errors.length} failed: ${errors.join("; ")}`
188
+ + ` — ${decision.reason}. ${formatIdleReading(decision.idle)}.`,
189
+ };
65
190
  }