omni-notify-mcp 1.1.7 → 1.2.0

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
@@ -57,23 +57,25 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
57
57
  Then run the config UI to wire up your channels:
58
58
 
59
59
  ```bash
60
- npx omni-notify-mcp ui
60
+ npx omni-notify-ui
61
61
  ```
62
62
 
63
63
  Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
64
64
 
65
65
  ## What the agent gets
66
66
 
67
- Six tools, all server-configured (the agent never names a channel):
67
+ Eight tools, all server-configured (the agent never names a channel):
68
68
 
69
69
  | Tool | What it does |
70
70
  |---|---|
71
71
  | **`notify`** | Send a message to the user. Priority controls fan-out (see below). |
72
72
  | **`ask`** | Send a question and **wait** for the user's reply (Telegram, or web link via email). |
73
73
  | **`poll`** | Drain any unsolicited messages the user sent. |
74
- | **`get_idle_seconds`** | Seconds since last keyboard/mouse input. -1 if unsupported. |
75
- | **`get_idle_config`** | The server's idle-gating policy `{ enabled, thresholdSeconds }`. |
76
- | **`get_dnd_status`** | Current DND state `{ active, reason }`. |
74
+ | **`wait_for_inbox`** | **Long-poll**: block up to 55s and return the moment the user types something. The most reliable push path across every MCP client — messages come back as tool *results*, not notifications (which many clients drop). |
75
+ | **`get_idle_seconds`** | Seconds since last keyboard/mouse input. Drains inbox as a side-effect. |
76
+ | **`get_idle_config`** | The server's idle-gating policy `{ enabled, thresholdSeconds }`. Drains inbox. |
77
+ | **`get_dnd_status`** | Current DND state `{ active, reason }`. Drains inbox. |
78
+ | **`reply`** *(stdio only)* | Channels return-path — Claude Code calls this when the agent responds to a pushed channel message. Routes straight through `notify`. |
77
79
 
78
80
  Priority routing for `notify`:
79
81
 
@@ -106,6 +108,65 @@ Run multiple agents against the same notify server (e.g. one Claude session in `
106
108
  - `priority='high'` always punches through.
107
109
  - Agents can pre-flight with `get_dnd_status` to skip the round-trip when DND is on.
108
110
 
111
+ ### Heartbeat-drain (stay responsive during long work)
112
+ Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets any pending user inbox messages piggy-backed on the response. The server-side MCP `instructions` tell agents to call `get_idle_seconds` every 15-30 seconds during long operations so a user ping from Telegram lands within 30 seconds even if the agent hasn't called `notify` in hours. When an inbox message lands, the agent is required to fire a terse `busy-ack` back via `notify` so the user knows they were heard — even if the full response comes later.
113
+
114
+ ### Multi-session broadcast
115
+ When multiple agents connect to the same server (e.g. one Claude per repo), every untagged user message is broadcast to all of them. Each agent replies with its session id, the user picks who they want to address, then targets follow-ups with `@<tag>`. The Telegram ack names the sessions the message was routed to.
116
+
117
+ ### Dual transport — HTTP and stdio (with Claude Code Channels)
118
+ `notify-mcp` ships two entrypoints against the same server state:
119
+
120
+ - **`omni-notify-mcp`** (stdio) — the default `npx omni-notify-mcp` command. Speaks stdio JSON-RPC, auto-spawns the HTTP server as a detached child if it isn't already running, and subscribes to the inbox SSE stream so it can push unsolicited messages to the attached agent. **Declares the `claude/channel` capability**, so Claude Code v2.1.80+ surfaces each user message as a synthetic turn via `notifications/claude/channel` — the only push path that crosses the client boundary reliably ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code/channels](https://code.claude.com/docs/en/channels)). When the host doesn't support Channels, the bridge still works — agents just use `wait_for_inbox` as the long-poll fallback.
121
+ - **`omni-notify-ui`** (HTTP, default `:3737`) — runs the config web UI, the Telegram listener, all channel implementations, and the Streamable-HTTP `/mcp` endpoint for remote / multi-session agents.
122
+
123
+ For Claude Code with Channels:
124
+ ```bash
125
+ claude --channels omni-notify-mcp
126
+ # or, during preview, if your plugin isn't allowlisted:
127
+ claude --dangerously-load-development-channels omni-notify-mcp
128
+ ```
129
+
130
+ For every other MCP client, the stdio command works as a plain MCP server:
131
+ ```json
132
+ {
133
+ "mcpServers": {
134
+ "notify": { "command": "npx", "args": ["omni-notify-mcp"] }
135
+ }
136
+ }
137
+ ```
138
+
139
+ ### Reliable push — `wait_for_inbox` long-poll
140
+ The hard truth: **most MCP clients silently drop generic server notifications** ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code#41733](https://github.com/anthropics/claude-code/issues/41733)). The only delivery paths that survive are (a) Claude Code's new Channels (`notifications/claude/channel`, handled by the stdio bridge above) and (b) tool *results*. `wait_for_inbox` is the universal fallback: the agent calls it, the server parks the request until the user types something, then resolves it with the message — which the client is forced to surface because it's a tool-call response. Default timeout is 50s to stay under the 60s JS SDK request ceiling ([typescript-sdk#245](https://github.com/modelcontextprotocol/typescript-sdk/issues/245)); the agent re-calls on empty in a tight loop. The MCP `instructions` block shipped with the server tells agents exactly this loop pattern, so no per-prompt nagging is needed.
141
+
142
+ ### Reconnect resilience
143
+ The server returns HTTP 404 on requests with a stale `mcp-session-id` (per the MCP Streamable HTTP spec), so a client that wakes up after a server restart automatically re-initializes on its next tool call instead of staying stuck with a dead session. Idle sessions are reaped aggressively (90-second timeout, since the heartbeat contract requires requests every 15–30s), dead SSE subscribers are pruned every 15s, and every broadcast ack runs a liveness probe before counting targets — so "Broadcast to N session(s)" always reflects who can actually receive. TCP keepalive is enabled on every incoming socket (15s probe) and the server writes an SSE `: keepalive` comment down every live MCP GET stream every 20s, which defeats proxy idle timeouts and surfaces half-open connections within a minute ([typescript-sdk#270](https://github.com/modelcontextprotocol/typescript-sdk/issues/270)). The stdio bridge transparently re-initializes a fresh HTTP session on 404 without the agent ever seeing a failure.
144
+
145
+ ### File-drop bridge for busy agents (the /btw mechanism)
146
+ Claude Code has no public API for injecting a prompt into a running session while a tool call is executing ([anthropics/claude-code#27441](https://github.com/anthropics/claude-code/issues/27441)). The MCP heartbeat-drain already handles agents that are *voluntarily polling*, but if the agent is deep in a 5-minute `Bash` or `WebFetch` call, the piggy-back never fires. For that case, the server drops every unsolicited inbox message as a markdown file at `~/.notify-mcp/inbox/<timestamp>.md`, so a Claude Code `FileChanged` hook can surface it on the very next turn without the agent having to cooperate.
147
+
148
+ Drop this into `~/.claude/settings.json` (or a project-local `.claude/settings.json`):
149
+
150
+ ```json
151
+ {
152
+ "hooks": {
153
+ "FileChanged": [
154
+ {
155
+ "matcher": "**/.notify-mcp/inbox/*.md",
156
+ "hooks": [
157
+ {
158
+ "type": "command",
159
+ "command": "cat \"$CLAUDE_FILE_PATH\" && rm \"$CLAUDE_FILE_PATH\""
160
+ }
161
+ ]
162
+ }
163
+ ]
164
+ }
165
+ }
166
+ ```
167
+
168
+ The hook's stdout is injected as additional context on the next turn, and the `rm` clears the drop so each message fires exactly once. Stale drops older than 24h are reaped by the server automatically.
169
+
109
170
  ### Idle gating (anti-buzz)
110
171
  The server publishes a policy `{ enabled, thresholdSeconds }`. Agents are **instructed** (via the MCP `instructions` field, surfaced to every connecting client) to call `get_idle_seconds` first, and **skip** sending a notification if you're actively at the keyboard. They can already see what they'd send. Only fire when you've stepped away. `priority='high'` always fires.
111
172
 
Binary file
Binary file
package/dist/index.js CHANGED
@@ -1,146 +1,313 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * Stdio bridge for notify-mcp.
4
+ *
5
+ * This is the Claude Code / Cursor / Codex stdio entrypoint. It is a *thin*
6
+ * foreground process — all state (config, inbox, pending asks, Telegram
7
+ * listener) lives in the long-running HTTP server (`ui/server.js`, default
8
+ * port 3737). The bridge:
9
+ *
10
+ * 1. Ensures the HTTP server is running (auto-spawns it detached if not).
11
+ * 2. Subscribes to /api/inbox/stream via SSE and re-emits each unsolicited
12
+ * user message as a `notifications/claude/channel` notification to the
13
+ * attached client. Claude Code (v2.1.80+) surfaces those as synthetic
14
+ * user turns, which is the only push path that reliably crosses the
15
+ * client boundary — regular MCP notifications are dropped by most
16
+ * clients (modelcontextprotocol/modelcontextprotocol#1192).
17
+ * 3. Exposes the full tool surface (`notify`, `ask`, `poll`, `wait_for_inbox`,
18
+ * `get_idle_seconds`, `get_idle_config`, `get_dnd_status`, `reply`) and
19
+ * proxies every call to the HTTP /mcp endpoint so multi-session routing,
20
+ * DND, idle gating, etc. all work identically to the HTTP transport.
21
+ *
22
+ * The net effect: users can `claude --channels notify-mcp@<registry>` (or
23
+ * run `npx omni-notify-mcp` as a plain stdio MCP server) and get push-to-agent
24
+ * delivery without ever touching a .mcp.json or a port number.
25
+ */
2
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
27
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { randomUUID } from "crypto";
28
+ import { spawn } from "child_process";
29
+ import { existsSync } from "fs";
30
+ import { join, dirname } from "path";
31
+ import { fileURLToPath } from "url";
5
32
  import { z } from "zod";
6
- import { loadConfig } from "./config.js";
7
- import { sendDesktop } from "./channels/desktop.js";
8
- import { sendTelegram } from "./channels/telegram.js";
9
- import { sendWhatsApp } from "./channels/whatsapp.js";
10
- import { sendSms } from "./channels/sms.js";
11
- import { sendEmail } from "./channels/email.js";
12
- const config = loadConfig();
13
- // ── Telegram listener ─────────────────────────────────────────────────────────
14
- const pendingAsks = new Map();
15
- const inboxQueue = [];
16
- let tgPollOffset = -1;
17
- async function initTgOffset(token) {
18
- const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
19
- const json = await r.json();
20
- const results = json.result ?? [];
21
- return results.length > 0 ? results[results.length - 1].update_id + 1 : 0;
33
+ const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
34
+ const BASE = `http://localhost:${PORT}`;
35
+ const SESSION_TAG = (process.env.NOTIFY_MCP_TAG ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
36
+ const CLIENT_NAME = "claude-channel-bridge";
37
+ // ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
38
+ async function serverIsUp() {
39
+ try {
40
+ const r = await fetch(`${BASE}/mcp`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" },
43
+ body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
44
+ signal: AbortSignal.timeout(1500),
45
+ });
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;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function spawnUiServerIfNeeded() {
55
+ const here = dirname(fileURLToPath(import.meta.url));
56
+ // dist/ layout: src/index.ts → dist/index.js; ui → dist/ui/server.js
57
+ const candidates = [
58
+ join(here, "ui", "server.js"),
59
+ join(here, "..", "ui", "server.js"),
60
+ join(here, "..", "dist", "ui", "server.js"),
61
+ ];
62
+ const uiPath = candidates.find(p => existsSync(p));
63
+ if (!uiPath) {
64
+ stderr(`[bridge] could not locate ui/server.js near ${here} — skipping auto-spawn`);
65
+ return;
66
+ }
67
+ stderr(`[bridge] auto-spawning UI server: ${uiPath}`);
68
+ const child = spawn(process.execPath, [uiPath], {
69
+ detached: true,
70
+ stdio: "ignore",
71
+ env: { ...process.env, PORT: String(PORT) },
72
+ });
73
+ child.unref();
74
+ }
75
+ async function waitForServer(maxMs = 15_000) {
76
+ const deadline = Date.now() + maxMs;
77
+ while (Date.now() < deadline) {
78
+ if (await serverIsUp())
79
+ return true;
80
+ await new Promise(r => setTimeout(r, 500));
81
+ }
82
+ return false;
22
83
  }
23
- async function startTelegramListener() {
84
+ function stderr(line) {
85
+ // stdio transport uses stdout for JSON-RPC — all logging MUST go to stderr.
86
+ try {
87
+ process.stderr.write(`${line}\n`);
88
+ }
89
+ catch { /* ignore */ }
90
+ }
91
+ // ── 2. HTTP /mcp session — the bridge itself is an MCP client of the server ──
92
+ // We run a single persistent MCP-over-HTTP session per bridge process and
93
+ // forward every local stdio tool call through it. Using one shared session
94
+ // (not per-tool-call) keeps the tag-based routing, waiter parking, and inbox
95
+ // draining all consistent with what the HTTP server sees.
96
+ let httpSessionId;
97
+ let httpRpcId = 1;
98
+ async function httpRpc(method, params, isNotification = false) {
99
+ // JSON-RPC notifications (method name starts with `notifications/` or the
100
+ // caller says so) carry no `id` and get no response. Spec-compliant servers
101
+ // return 202 Accepted with empty body.
102
+ const notif = isNotification || method.startsWith("notifications/");
103
+ const body = { jsonrpc: "2.0", method, params: params ?? {} };
104
+ if (!notif)
105
+ body.id = httpRpcId++;
106
+ const headers = {
107
+ "Content-Type": "application/json",
108
+ "Accept": "application/json, text/event-stream",
109
+ };
110
+ if (httpSessionId)
111
+ headers["mcp-session-id"] = httpSessionId;
112
+ const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
113
+ const r = await fetch(`${BASE}/mcp${tagQuery}`, {
114
+ method: "POST",
115
+ headers,
116
+ body: JSON.stringify(body),
117
+ // Long-poll tools may block up to ~55s server-side. Give the fetch
118
+ // generous headroom but not infinite, so a wedged server surfaces fast.
119
+ signal: AbortSignal.timeout(120_000),
120
+ });
121
+ // The server returns 404 when our cached session is stale (spec-compliant
122
+ // behavior after a server restart). Clear and retry once with a fresh init.
123
+ if (r.status === 404 && httpSessionId) {
124
+ httpSessionId = undefined;
125
+ await httpInitialize();
126
+ return httpRpc(method, params, isNotification);
127
+ }
128
+ if (r.status >= 500) {
129
+ throw new Error(`HTTP ${r.status} from /mcp: ${await r.text().catch(() => "")}`);
130
+ }
131
+ const sid = r.headers.get("mcp-session-id");
132
+ if (sid && !httpSessionId)
133
+ httpSessionId = sid;
134
+ if (notif)
135
+ return undefined;
136
+ const ctype = r.headers.get("content-type") ?? "";
137
+ const raw = await r.text();
138
+ if (ctype.includes("application/json")) {
139
+ return JSON.parse(raw);
140
+ }
141
+ // SSE framing: pull the first data: line as the JSON-RPC response.
142
+ for (const line of raw.split(/\r?\n/)) {
143
+ if (line.startsWith("data:")) {
144
+ const json = line.slice(5).trim();
145
+ if (json)
146
+ return JSON.parse(json);
147
+ }
148
+ }
149
+ throw new Error(`unexpected response from /mcp: ${raw.slice(0, 200)}`);
150
+ }
151
+ async function httpInitialize() {
152
+ const res = await httpRpc("initialize", {
153
+ protocolVersion: "2024-11-05",
154
+ capabilities: {},
155
+ clientInfo: { name: CLIENT_NAME, version: "1.0" },
156
+ });
157
+ if (res?.error)
158
+ throw new Error(`initialize failed: ${JSON.stringify(res.error)}`);
159
+ // Follow-up: the spec requires a notifications/initialized after initialize.
160
+ await httpRpc("notifications/initialized").catch(() => { });
161
+ }
162
+ // ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
163
+ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
164
+ // Declare the claude/channel capability so Claude Code knows to surface
165
+ // our notifications/claude/channel events as synthetic user turns. The
166
+ // `reply` tool below is how Claude hands the agent's response back to us.
167
+ // Reference: https://code.claude.com/docs/en/channels-reference
168
+ capabilities: {
169
+ experimental: { "claude/channel": {} },
170
+ },
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.",
175
+ });
176
+ // Thin proxy: forward a tool call to the HTTP server and return its content
177
+ // block array verbatim. Error shape matches what the SDK expects from tool
178
+ // handlers.
179
+ async function proxyToolCall(name, args) {
180
+ const res = await httpRpc("tools/call", { name, arguments: args });
181
+ if (res?.error) {
182
+ return { content: [{ type: "text", text: `Error: ${res.error.message ?? JSON.stringify(res.error)}` }], isError: true };
183
+ }
184
+ const result = res?.result;
185
+ if (result?.content && Array.isArray(result.content)) {
186
+ return { content: result.content, isError: !!result.isError };
187
+ }
188
+ return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
189
+ }
190
+ server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured.", {
191
+ message: z.string().max(500),
192
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
193
+ }, async (args) => proxyToolCall("notify", args));
194
+ server.tool("ask", "Send a question to the user and wait for their reply.", {
195
+ question: z.string().max(500),
196
+ timeout_seconds: z.number().min(30).max(3600).default(300),
197
+ }, async (args) => proxyToolCall("ask", args));
198
+ server.tool("poll", "Drain pending unsolicited user messages.", {}, async () => proxyToolCall("poll", {}));
199
+ server.tool("wait_for_inbox", "Block until an unsolicited user message arrives or timeout expires. Reliable " +
200
+ "delivery across every MCP client (messages come back as tool results).", {
201
+ timeout_seconds: z.number().min(5).max(55).default(50),
202
+ }, async (args) => proxyToolCall("wait_for_inbox", args));
203
+ server.tool("get_idle_seconds", "Seconds since user's last keyboard/mouse input. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_seconds", {}));
204
+ server.tool("get_idle_config", "Server's idle gating policy. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_config", {}));
205
+ server.tool("get_dnd_status", "Current DND state. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_dnd_status", {}));
206
+ // `reply` is the Channels return-path: Claude Code invokes it when the agent
207
+ // has produced a response to a channel-delivered user message. We just funnel
208
+ // it straight through `notify` so it flows to whatever channel the user is
209
+ // actually reading (Telegram, desktop, email, ...).
210
+ server.tool("reply", "Reply to the user's most recent channel message. Routes through notify so " +
211
+ "the response reaches whichever channel the user is reading.", {
212
+ message: z.string().max(2000).describe("The reply text to deliver"),
213
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
214
+ }, async ({ message, priority }) => {
215
+ const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
216
+ return proxyToolCall("notify", { message: `${tagPrefix}${message}`, priority });
217
+ });
218
+ async function subscribeInbox() {
219
+ const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
220
+ // Reconnect forever with backoff. We don't care about replay; the
221
+ // file-drop bridge and queue handle the "missed while offline" window.
222
+ let backoff = 1000;
24
223
  while (true) {
25
224
  try {
26
- const cfg = loadConfig();
27
- const { token, chatId } = cfg.telegram ?? {};
28
- if (!token || !chatId || !cfg.telegram?.enabled) {
29
- await new Promise(r => setTimeout(r, 5000));
30
- continue;
31
- }
32
- if (tgPollOffset < 0) {
33
- tgPollOffset = await initTgOffset(token);
34
- }
35
- const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${tgPollOffset}&timeout=10`);
36
- const json = await r.json();
37
- for (const update of json.result ?? []) {
38
- tgPollOffset = update.update_id + 1;
39
- const msg = update.message;
40
- if (msg?.chat?.id?.toString() === chatId && msg.text) {
41
- const first = [...pendingAsks.entries()][0];
42
- if (first) {
43
- const [id, pending] = first;
44
- clearTimeout(pending.timer);
45
- pendingAsks.delete(id);
46
- pending.resolve(msg.text);
47
- }
48
- else {
49
- inboxQueue.push({ text: msg.text, ts: new Date().toISOString() });
225
+ const r = await fetch(`${BASE}/api/inbox/stream${tagQuery}`, {
226
+ headers: { "Accept": "text/event-stream" },
227
+ });
228
+ if (!r.ok || !r.body)
229
+ throw new Error(`stream HTTP ${r.status}`);
230
+ backoff = 1000; // reset on successful connect
231
+ const reader = r.body.getReader();
232
+ const decoder = new TextDecoder();
233
+ let buffer = "";
234
+ while (true) {
235
+ const { value, done } = await reader.read();
236
+ if (done)
237
+ break;
238
+ buffer += decoder.decode(value, { stream: true });
239
+ let idx;
240
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
241
+ const frame = buffer.slice(0, idx);
242
+ buffer = buffer.slice(idx + 2);
243
+ // SSE frame: may include multiple "data:" lines. We emit on each.
244
+ for (const line of frame.split(/\r?\n/)) {
245
+ if (!line.startsWith("data:"))
246
+ continue;
247
+ const payload = line.slice(5).trim();
248
+ if (!payload)
249
+ continue;
250
+ try {
251
+ const entry = JSON.parse(payload);
252
+ await emitChannelEvent(entry);
253
+ }
254
+ catch (err) {
255
+ stderr(`[bridge] bad SSE payload: ${err instanceof Error ? err.message : String(err)}`);
256
+ }
50
257
  }
51
258
  }
52
259
  }
53
260
  }
54
261
  catch (err) {
55
- const msg = err instanceof Error ? err.message : String(err);
56
- if (!msg.includes("terminated") && !msg.includes("aborted")) {
57
- await new Promise(r => setTimeout(r, 2000));
58
- }
262
+ stderr(`[bridge] inbox stream closed: ${err instanceof Error ? err.message : String(err)}`);
59
263
  }
264
+ await new Promise(r => setTimeout(r, backoff));
265
+ backoff = Math.min(30_000, backoff * 2);
60
266
  }
61
267
  }
62
- startTelegramListener();
63
- // ── MCP server ────────────────────────────────────────────────────────────────
64
- const server = new McpServer({
65
- name: "omni-notify-mcp",
66
- version: "1.0.0",
67
- });
68
- server.tool("notify", "Send a notification through configured channels (desktop, Telegram, WhatsApp, SMS, email). " +
69
- "Priority: low=email only; normal=desktop+telegram+email; high=all channels. " +
70
- "Use for: task milestones, questions needing user input, catastrophic findings, long task completion.", {
71
- message: z.string().max(500).describe("Notification message, max 500 chars"),
72
- priority: z
73
- .enum(["low", "normal", "high"])
74
- .default("normal")
75
- .describe("low=email only; normal=desktop+telegram+email; high=desktop+telegram+whatsapp+sms+email"),
76
- }, async ({ message, priority }) => {
77
- const results = [];
78
- const errors = [];
79
- const send = async (name, fn) => {
80
- try {
81
- await fn();
82
- results.push(name);
83
- }
84
- catch (err) {
85
- errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
268
+ async function emitChannelEvent(entry) {
269
+ // Emit the new Claude Code Channels notification *and* the generic
270
+ // `notifications/message` as a belt-and-suspenders approach: hosts that
271
+ // ignore `notifications/claude/channel` may still surface the message as
272
+ // a log line. Both are fire-and-forget; a failure just means the peer
273
+ // closed the stdio transport (we'll notice on the next tool call).
274
+ const content = entry.tag ? `[@${entry.tag}] ${entry.text}` : entry.text;
275
+ try {
276
+ await server.server.notification({
277
+ method: "notifications/claude/channel",
278
+ params: {
279
+ content,
280
+ meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
281
+ },
282
+ });
283
+ }
284
+ catch {
285
+ // client doesn't support the experimental capability — that's fine,
286
+ // wait_for_inbox is the universal fallback.
287
+ }
288
+ }
289
+ // ── 5. Wire it up ────────────────────────────────────────────────────────────
290
+ async function main() {
291
+ if (!(await serverIsUp())) {
292
+ spawnUiServerIfNeeded();
293
+ if (!(await waitForServer())) {
294
+ stderr(`[bridge] HTTP server at ${BASE} did not come up within 15s — giving up on push; tool calls will fail.`);
86
295
  }
87
- };
88
- if (priority === "normal" || priority === "high") {
89
- await send("desktop", () => sendDesktop(config.desktop, message));
90
- await send("telegram", () => sendTelegram(config.telegram, message));
91
- }
92
- if (priority === "high") {
93
- await send("whatsapp", () => sendWhatsApp(config.whatsapp, message));
94
- await send("sms", () => sendSms(config.sms, message));
95
- }
96
- await send("email", () => sendEmail(config.email, message));
97
- const summary = [
98
- results.length > 0 ? `Sent via: ${results.join(", ")}` : null,
99
- errors.length > 0 ? `Errors: ${errors.join("; ")}` : null,
100
- ].filter(Boolean).join(" | ");
101
- return { content: [{ type: "text", text: summary || "No channels delivered" }] };
102
- });
103
- server.tool("ask", "Send a question to the user via Telegram and wait for their reply. " +
104
- "Use when a decision is needed before continuing — e.g. 'Should I delete these files?'", {
105
- question: z.string().max(500).describe("The question to ask the user"),
106
- timeout_seconds: z.number().min(30).max(3600).default(300)
107
- .describe("How long to wait for a reply in seconds (default 5 min)"),
108
- }, async ({ question, timeout_seconds = 300 }) => {
109
- const cfg = loadConfig();
110
- if (!cfg.telegram?.enabled || !cfg.telegram.token || !cfg.telegram.chatId) {
111
- return { content: [{ type: "text", text: "Error: Telegram not configured. Enable it in ~/.notify-mcp/config.json" }] };
112
- }
113
- await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
114
- method: "POST",
115
- headers: { "Content-Type": "application/json" },
116
- body: JSON.stringify({
117
- chat_id: cfg.telegram.chatId,
118
- text: `❓ ${question}\n\nReply to this message with your answer.`,
119
- }),
120
- });
121
- const token = randomUUID();
122
- const reply = await new Promise((resolve, reject) => {
123
- const timer = setTimeout(() => {
124
- pendingAsks.delete(token);
125
- reject(new Error(`No reply received within ${timeout_seconds}s`));
126
- }, timeout_seconds * 1000);
127
- pendingAsks.set(token, { resolve, timer });
128
- });
129
- return { content: [{ type: "text", text: reply }] };
130
- });
131
- server.tool("poll", "Check for unsolicited messages the user sent on Telegram (not in response to an ask). " +
132
- "Returns queued messages and clears the queue. Returns 'inbox:empty' if nothing pending. " +
133
- "Call this at the start of each work cycle.", {}, async () => {
134
- if (inboxQueue.length === 0) {
135
- return { content: [{ type: "text", text: "inbox:empty" }] };
136
- }
137
- const messages = inboxQueue.splice(0);
138
- return {
139
- content: [{
140
- type: "text",
141
- text: messages.map(m => `[${m.ts}] ${m.text}`).join("\n"),
142
- }],
143
- };
296
+ }
297
+ try {
298
+ await httpInitialize();
299
+ }
300
+ catch (err) {
301
+ stderr(`[bridge] initial HTTP initialize failed: ${err instanceof Error ? err.message : String(err)}`);
302
+ }
303
+ // Fire-and-forget: the stdio transport should be usable immediately; the
304
+ // push channel attaches as soon as the SSE handshake completes.
305
+ subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
306
+ const transport = new StdioServerTransport();
307
+ await server.connect(transport);
308
+ stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
309
+ }
310
+ main().catch(err => {
311
+ stderr(`[bridge] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
312
+ process.exit(1);
144
313
  });
145
- const transport = new StdioServerTransport();
146
- await server.connect(transport);