omni-notify-mcp 1.1.8 → 1.2.1
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 +58 -6
- package/dist/index.js +302 -129
- package/dist/ui/server.js +330 -16
- package/package.json +4 -2
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-
|
|
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
|
-
|
|
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
|
-
| **`
|
|
75
|
-
| **`
|
|
76
|
-
| **`
|
|
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
|
|
|
@@ -112,8 +114,58 @@ Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets an
|
|
|
112
114
|
### Multi-session broadcast
|
|
113
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.
|
|
114
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
|
+
|
|
115
142
|
### Reconnect resilience
|
|
116
|
-
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
|
|
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.
|
|
117
169
|
|
|
118
170
|
### Idle gating (anti-buzz)
|
|
119
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.
|
package/dist/index.js
CHANGED
|
@@ -1,146 +1,319 @@
|
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.\n\n" +
|
|
175
|
+
"ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
|
|
176
|
+
"just finished (success or failure), (b) you have a question or need a " +
|
|
177
|
+
"decision, (c) something important happened the user needs to know right " +
|
|
178
|
+
"now. Idle/DND gating is handled server-side — fire notify and let the " +
|
|
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.",
|
|
181
|
+
});
|
|
182
|
+
// Thin proxy: forward a tool call to the HTTP server and return its content
|
|
183
|
+
// block array verbatim. Error shape matches what the SDK expects from tool
|
|
184
|
+
// handlers.
|
|
185
|
+
async function proxyToolCall(name, args) {
|
|
186
|
+
const res = await httpRpc("tools/call", { name, arguments: args });
|
|
187
|
+
if (res?.error) {
|
|
188
|
+
return { content: [{ type: "text", text: `Error: ${res.error.message ?? JSON.stringify(res.error)}` }], isError: true };
|
|
189
|
+
}
|
|
190
|
+
const result = res?.result;
|
|
191
|
+
if (result?.content && Array.isArray(result.content)) {
|
|
192
|
+
return { content: result.content, isError: !!result.isError };
|
|
193
|
+
}
|
|
194
|
+
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
195
|
+
}
|
|
196
|
+
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured.", {
|
|
197
|
+
message: z.string().max(500),
|
|
198
|
+
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
199
|
+
}, async (args) => proxyToolCall("notify", args));
|
|
200
|
+
server.tool("ask", "Send a question to the user and wait for their reply.", {
|
|
201
|
+
question: z.string().max(500),
|
|
202
|
+
timeout_seconds: z.number().min(30).max(3600).default(300),
|
|
203
|
+
}, async (args) => proxyToolCall("ask", args));
|
|
204
|
+
server.tool("poll", "Drain pending unsolicited user messages.", {}, async () => proxyToolCall("poll", {}));
|
|
205
|
+
server.tool("wait_for_inbox", "Block until an unsolicited user message arrives or timeout expires. Reliable " +
|
|
206
|
+
"delivery across every MCP client (messages come back as tool results).", {
|
|
207
|
+
timeout_seconds: z.number().min(5).max(55).default(50),
|
|
208
|
+
}, async (args) => proxyToolCall("wait_for_inbox", args));
|
|
209
|
+
server.tool("get_idle_seconds", "Seconds since user's last keyboard/mouse input. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_seconds", {}));
|
|
210
|
+
server.tool("get_idle_config", "Server's idle gating policy. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_config", {}));
|
|
211
|
+
server.tool("get_dnd_status", "Current DND state. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_dnd_status", {}));
|
|
212
|
+
// `reply` is the Channels return-path: Claude Code invokes it when the agent
|
|
213
|
+
// has produced a response to a channel-delivered user message. We just funnel
|
|
214
|
+
// it straight through `notify` so it flows to whatever channel the user is
|
|
215
|
+
// actually reading (Telegram, desktop, email, ...).
|
|
216
|
+
server.tool("reply", "Reply to the user's most recent channel message. Routes through notify so " +
|
|
217
|
+
"the response reaches whichever channel the user is reading.", {
|
|
218
|
+
message: z.string().max(2000).describe("The reply text to deliver"),
|
|
219
|
+
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
220
|
+
}, async ({ message, priority }) => {
|
|
221
|
+
const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
|
|
222
|
+
return proxyToolCall("notify", { message: `${tagPrefix}${message}`, priority });
|
|
223
|
+
});
|
|
224
|
+
async function subscribeInbox() {
|
|
225
|
+
const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
|
|
226
|
+
// Reconnect forever with backoff. We don't care about replay; the
|
|
227
|
+
// file-drop bridge and queue handle the "missed while offline" window.
|
|
228
|
+
let backoff = 1000;
|
|
24
229
|
while (true) {
|
|
25
230
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
231
|
+
const r = await fetch(`${BASE}/api/inbox/stream${tagQuery}`, {
|
|
232
|
+
headers: { "Accept": "text/event-stream" },
|
|
233
|
+
});
|
|
234
|
+
if (!r.ok || !r.body)
|
|
235
|
+
throw new Error(`stream HTTP ${r.status}`);
|
|
236
|
+
backoff = 1000; // reset on successful connect
|
|
237
|
+
const reader = r.body.getReader();
|
|
238
|
+
const decoder = new TextDecoder();
|
|
239
|
+
let buffer = "";
|
|
240
|
+
while (true) {
|
|
241
|
+
const { value, done } = await reader.read();
|
|
242
|
+
if (done)
|
|
243
|
+
break;
|
|
244
|
+
buffer += decoder.decode(value, { stream: true });
|
|
245
|
+
let idx;
|
|
246
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
247
|
+
const frame = buffer.slice(0, idx);
|
|
248
|
+
buffer = buffer.slice(idx + 2);
|
|
249
|
+
// SSE frame: may include multiple "data:" lines. We emit on each.
|
|
250
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
251
|
+
if (!line.startsWith("data:"))
|
|
252
|
+
continue;
|
|
253
|
+
const payload = line.slice(5).trim();
|
|
254
|
+
if (!payload)
|
|
255
|
+
continue;
|
|
256
|
+
try {
|
|
257
|
+
const entry = JSON.parse(payload);
|
|
258
|
+
await emitChannelEvent(entry);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
stderr(`[bridge] bad SSE payload: ${err instanceof Error ? err.message : String(err)}`);
|
|
262
|
+
}
|
|
50
263
|
}
|
|
51
264
|
}
|
|
52
265
|
}
|
|
53
266
|
}
|
|
54
267
|
catch (err) {
|
|
55
|
-
|
|
56
|
-
if (!msg.includes("terminated") && !msg.includes("aborted")) {
|
|
57
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
58
|
-
}
|
|
268
|
+
stderr(`[bridge] inbox stream closed: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
269
|
}
|
|
270
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
271
|
+
backoff = Math.min(30_000, backoff * 2);
|
|
60
272
|
}
|
|
61
273
|
}
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
274
|
+
async function emitChannelEvent(entry) {
|
|
275
|
+
// Emit the new Claude Code Channels notification *and* the generic
|
|
276
|
+
// `notifications/message` as a belt-and-suspenders approach: hosts that
|
|
277
|
+
// ignore `notifications/claude/channel` may still surface the message as
|
|
278
|
+
// a log line. Both are fire-and-forget; a failure just means the peer
|
|
279
|
+
// closed the stdio transport (we'll notice on the next tool call).
|
|
280
|
+
const content = entry.tag ? `[@${entry.tag}] ${entry.text}` : entry.text;
|
|
281
|
+
try {
|
|
282
|
+
await server.server.notification({
|
|
283
|
+
method: "notifications/claude/channel",
|
|
284
|
+
params: {
|
|
285
|
+
content,
|
|
286
|
+
meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// client doesn't support the experimental capability — that's fine,
|
|
292
|
+
// wait_for_inbox is the universal fallback.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// ── 5. Wire it up ────────────────────────────────────────────────────────────
|
|
296
|
+
async function main() {
|
|
297
|
+
if (!(await serverIsUp())) {
|
|
298
|
+
spawnUiServerIfNeeded();
|
|
299
|
+
if (!(await waitForServer())) {
|
|
300
|
+
stderr(`[bridge] HTTP server at ${BASE} did not come up within 15s — giving up on push; tool calls will fail.`);
|
|
86
301
|
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
await
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
});
|
|
103
|
-
|
|
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
|
-
};
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await httpInitialize();
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
stderr(`[bridge] initial HTTP initialize failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
+
}
|
|
309
|
+
// Fire-and-forget: the stdio transport should be usable immediately; the
|
|
310
|
+
// push channel attaches as soon as the SSE handshake completes.
|
|
311
|
+
subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
|
|
312
|
+
const transport = new StdioServerTransport();
|
|
313
|
+
await server.connect(transport);
|
|
314
|
+
stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
|
|
315
|
+
}
|
|
316
|
+
main().catch(err => {
|
|
317
|
+
stderr(`[bridge] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
318
|
+
process.exit(1);
|
|
144
319
|
});
|
|
145
|
-
const transport = new StdioServerTransport();
|
|
146
|
-
await server.connect(transport);
|
package/dist/ui/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import { randomUUID } from "crypto";
|
|
2
3
|
import express from "express";
|
|
3
4
|
import { google } from "googleapis";
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
5
6
|
import { homedir, networkInterfaces } from "os";
|
|
6
7
|
import { join } from "path";
|
|
7
8
|
import { fileURLToPath } from "url";
|
|
@@ -747,6 +748,21 @@ function getLocalIp() {
|
|
|
747
748
|
}
|
|
748
749
|
const pendingAsks = new Map();
|
|
749
750
|
const inboxQueue = [];
|
|
751
|
+
const inboxWaiters = new Map();
|
|
752
|
+
// Match waiters the same way `matchesSession` matches SSE subscribers:
|
|
753
|
+
// - untagged entry → every waiter is a match (broadcast)
|
|
754
|
+
// - tagged entry → only waiters with the same tag match
|
|
755
|
+
function takeWaitersFor(entryTag) {
|
|
756
|
+
const taken = [];
|
|
757
|
+
for (const [id, w] of inboxWaiters) {
|
|
758
|
+
const match = entryTag === undefined ? true : w.tag === entryTag;
|
|
759
|
+
if (match) {
|
|
760
|
+
inboxWaiters.delete(id);
|
|
761
|
+
taken.push(w);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return taken;
|
|
765
|
+
}
|
|
750
766
|
let tgPollOffset = -1;
|
|
751
767
|
let lastUserMessageId;
|
|
752
768
|
// When the user pings us from Telegram, bypass idle-gating on outbound
|
|
@@ -772,19 +788,110 @@ function matchesSession(entry, sessionTag) {
|
|
|
772
788
|
return true; // untagged → everyone
|
|
773
789
|
return entry.tag === sessionTag; // tagged → only matching session
|
|
774
790
|
}
|
|
791
|
+
// ── /btw file-drop bridge ─────────────────────────────────────────────────────
|
|
792
|
+
// Claude Code has no API for injecting a prompt into a running session while a
|
|
793
|
+
// tool call is executing (anthropics/claude-code#27441, still open). The only
|
|
794
|
+
// in-band channel is the `FileChanged` hook: when a watched file changes on
|
|
795
|
+
// disk, Claude Code's hook script stdout is injected as additional context on
|
|
796
|
+
// the next turn — without the agent having to poll.
|
|
797
|
+
//
|
|
798
|
+
// We drop every unsolicited user message into ~/.notify-mcp/inbox/<ts>.md, and
|
|
799
|
+
// ship a one-liner hook in the README that globs that directory. This is the
|
|
800
|
+
// closest thing to a "/btw" we can get until the client exposes a real inject
|
|
801
|
+
// endpoint.
|
|
802
|
+
const INBOX_DROP_DIR = join(CONFIG_DIR, "inbox");
|
|
803
|
+
const INBOX_DROP_TTL_MS = 24 * 60 * 60 * 1000; // 24h — hook should have consumed within seconds
|
|
804
|
+
function writeInboxDrop(entry) {
|
|
805
|
+
try {
|
|
806
|
+
if (!existsSync(INBOX_DROP_DIR))
|
|
807
|
+
mkdirSync(INBOX_DROP_DIR, { recursive: true });
|
|
808
|
+
const safeTs = entry.ts.replace(/[:.]/g, "-");
|
|
809
|
+
const tagPart = entry.tag ? `.${entry.tag}` : "";
|
|
810
|
+
const path = join(INBOX_DROP_DIR, `${safeTs}${tagPart}.md`);
|
|
811
|
+
const header = `# Unsolicited user message\n\n` +
|
|
812
|
+
`- Time: ${entry.ts}\n` +
|
|
813
|
+
(entry.tag ? `- Tag: @${entry.tag}\n` : "") +
|
|
814
|
+
`- Origin: user (out-of-band)\n\n`;
|
|
815
|
+
writeFileSync(path, header + entry.text + "\n");
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// Reap old drops so the directory doesn't grow forever. Hooks consume within
|
|
822
|
+
// seconds, so anything older than a day is a message the agent never saw —
|
|
823
|
+
// keep it for forensics but eventually clean up.
|
|
824
|
+
setInterval(() => {
|
|
825
|
+
try {
|
|
826
|
+
if (!existsSync(INBOX_DROP_DIR))
|
|
827
|
+
return;
|
|
828
|
+
const now = Date.now();
|
|
829
|
+
const files = readdirSync(INBOX_DROP_DIR);
|
|
830
|
+
for (const f of files) {
|
|
831
|
+
const p = join(INBOX_DROP_DIR, f);
|
|
832
|
+
try {
|
|
833
|
+
const st = statSync(p);
|
|
834
|
+
if (now - st.mtimeMs > INBOX_DROP_TTL_MS)
|
|
835
|
+
unlinkSync(p);
|
|
836
|
+
}
|
|
837
|
+
catch { /* ignore */ }
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch { /* ignore */ }
|
|
841
|
+
}, 60 * 60 * 1000);
|
|
775
842
|
const inboxStreamClients = new Set();
|
|
776
843
|
function broadcastInbox(entry) {
|
|
777
844
|
const payload = JSON.stringify(entry);
|
|
845
|
+
let delivered = 0;
|
|
778
846
|
for (const c of inboxStreamClients) {
|
|
847
|
+
// Proactively drop subscribers whose socket is gone. Node's req.on("close")
|
|
848
|
+
// isn't reliable on every disconnect path (e.g. VS Code window killed hard,
|
|
849
|
+
// laptop lid shut), so check writability before every write.
|
|
850
|
+
if (c.res.destroyed || c.res.writableEnded || !c.res.writable) {
|
|
851
|
+
inboxStreamClients.delete(c);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
779
854
|
if (!matchesSession(entry, c.tag))
|
|
780
855
|
continue;
|
|
781
856
|
try {
|
|
782
857
|
c.res.write(`data: ${payload}\n\n`);
|
|
858
|
+
delivered++;
|
|
783
859
|
}
|
|
784
860
|
catch {
|
|
785
|
-
|
|
861
|
+
inboxStreamClients.delete(c);
|
|
786
862
|
}
|
|
787
863
|
}
|
|
864
|
+
return delivered;
|
|
865
|
+
}
|
|
866
|
+
// Test-only: inject a fake inbox entry exactly as the Telegram listener would.
|
|
867
|
+
// Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
|
|
868
|
+
// production run. Used by the test suite to drive wait_for_inbox wake-up and
|
|
869
|
+
// SSE broadcast paths without needing a real Telegram bot.
|
|
870
|
+
if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
|
|
871
|
+
app.post("/__test__/inject-inbox", express.json(), (req, res) => {
|
|
872
|
+
const text = String(req.body?.text ?? "");
|
|
873
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
874
|
+
if (!text) {
|
|
875
|
+
res.status(400).json({ error: "text required" });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const entry = { text, ts: new Date().toISOString(), tag };
|
|
879
|
+
const waiters = takeWaitersFor(tag);
|
|
880
|
+
if (waiters.length > 0) {
|
|
881
|
+
for (const w of waiters) {
|
|
882
|
+
clearTimeout(w.timer);
|
|
883
|
+
w.resolve([entry]);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
inboxQueue.push(entry);
|
|
888
|
+
}
|
|
889
|
+
const sse = broadcastInbox(entry);
|
|
890
|
+
writeInboxDrop(entry);
|
|
891
|
+
log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
|
|
892
|
+
res.json({ injected: true, waiters: waiters.length, sse });
|
|
893
|
+
});
|
|
894
|
+
log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
|
|
788
895
|
}
|
|
789
896
|
app.get("/api/inbox/stream", (req, res) => {
|
|
790
897
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -885,9 +992,30 @@ async function startTelegramListener() {
|
|
|
885
992
|
const entry = {
|
|
886
993
|
text, ts: new Date().toISOString(), messageId: msg.message_id, tag,
|
|
887
994
|
};
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
995
|
+
// Waiters (wait_for_inbox long-poll) get first crack — they were
|
|
996
|
+
// already parked by an agent explicitly asking "wake me up when
|
|
997
|
+
// something arrives." Hand the entry off as a tool *result*, which
|
|
998
|
+
// every MCP client actually surfaces. Only queue if no one was
|
|
999
|
+
// waiting, so the message isn't delivered twice.
|
|
1000
|
+
const waiters = takeWaitersFor(tag);
|
|
1001
|
+
if (waiters.length > 0) {
|
|
1002
|
+
for (const w of waiters) {
|
|
1003
|
+
clearTimeout(w.timer);
|
|
1004
|
+
w.resolve([entry]);
|
|
1005
|
+
}
|
|
1006
|
+
log("·", "inbox", `${text} → ${waiters.length} long-poll waiter(s)`, tag);
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
inboxQueue.push(entry);
|
|
1010
|
+
}
|
|
1011
|
+
writeInboxDrop(entry);
|
|
1012
|
+
const liveSseCount = broadcastInbox(entry);
|
|
1013
|
+
log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
|
|
1014
|
+
// Before building the ack, prune sessions whose transport stream
|
|
1015
|
+
// is dead or whose heartbeat has lapsed. Without this the ack
|
|
1016
|
+
// cheerfully claims "broadcast to 3 sessions" when 2 of them are
|
|
1017
|
+
// closed VS Code windows — which is exactly what prompted this fix.
|
|
1018
|
+
pruneDeadSessions();
|
|
891
1019
|
// Build an ack that names the active sessions the user's message
|
|
892
1020
|
// is being routed to. If the user tagged it and no session with
|
|
893
1021
|
// that tag is connected, tell them plainly so they don't sit
|
|
@@ -988,12 +1116,37 @@ channels are in use — just call 'notify' or 'ask' with a generic message.
|
|
|
988
1116
|
|
|
989
1117
|
BEHAVIORAL RULES for every client that connects:
|
|
990
1118
|
|
|
991
|
-
1.
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1119
|
+
1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
|
|
1120
|
+
the server decides routing, you decide whether to fire:
|
|
1121
|
+
|
|
1122
|
+
(a) LONG PROCESSING FINISHED. Any single task that took more than ~60
|
|
1123
|
+
seconds of wall-clock time (long build, test run, backtest, migration,
|
|
1124
|
+
big refactor, multi-step plan) gets a 'notify' the moment it completes
|
|
1125
|
+
— success OR failure. Rule of thumb: if the user could have reasonably
|
|
1126
|
+
walked away to grab coffee while you ran, they need a ping on the way
|
|
1127
|
+
back. Don't try to guess whether they were watching. Just notify.
|
|
1128
|
+
|
|
1129
|
+
(b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
|
|
1130
|
+
the user something — "should I delete these?", "which branch?",
|
|
1131
|
+
"proceed with plan B?" — fire 'notify' (or 'ask' for blocking
|
|
1132
|
+
two-way). Silent questions in the terminal get missed; a notification
|
|
1133
|
+
does not.
|
|
1134
|
+
|
|
1135
|
+
(c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
|
|
1136
|
+
now. Examples: a test suddenly failed after being green, a destructive
|
|
1137
|
+
operation is about to run, you found a security issue, a deploy
|
|
1138
|
+
succeeded, a production service looks degraded, you hit an
|
|
1139
|
+
unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
|
|
1140
|
+
NOTIFYING — the server's idle gating will automatically downgrade a
|
|
1141
|
+
mis-judged 'normal' to a silent desktop banner if the user is active,
|
|
1142
|
+
so the cost of over-notifying is near zero. The cost of missing a
|
|
1143
|
+
real event is that the user finds out 4 hours later.
|
|
1144
|
+
|
|
1145
|
+
The SERVER handles all routing (DND, idle threshold, channel selection,
|
|
1146
|
+
priority escalation). You do NOT need to pre-flight with
|
|
1147
|
+
'get_idle_seconds' before these three triggers — fire 'notify' and let
|
|
1148
|
+
the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
|
|
1149
|
+
not a gate on legitimate milestones.
|
|
997
1150
|
|
|
998
1151
|
2. Use priority correctly:
|
|
999
1152
|
- 'low' = email only — for low-stakes status (background completion).
|
|
@@ -1011,8 +1164,9 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1011
1164
|
user has already configured. Say 'notif' or 'notification' instead.
|
|
1012
1165
|
|
|
1013
1166
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
1014
|
-
the 'notify' response, via 'poll', via '
|
|
1015
|
-
via the /api/inbox/stream SSE), reply to
|
|
1167
|
+
the 'notify' response, via 'poll', via 'wait_for_inbox', via
|
|
1168
|
+
'get_idle_seconds' piggy-back, or via the /api/inbox/stream SSE), reply to
|
|
1169
|
+
them THROUGH 'notify' so the reply
|
|
1016
1170
|
actually reaches them — not just in your chat output. Multiple agents may
|
|
1017
1171
|
be connected simultaneously — the server broadcasts every untagged inbox
|
|
1018
1172
|
message to all of them, so the user can see who is listening. Your reply
|
|
@@ -1046,6 +1200,16 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1046
1200
|
hours away during long work. Treat 'get_idle_seconds' as the "check for
|
|
1047
1201
|
user input" primitive, not an idle-gate check.
|
|
1048
1202
|
|
|
1203
|
+
If your work is naturally idle (waiting for the user, between loop iters),
|
|
1204
|
+
prefer 'wait_for_inbox' instead — it blocks up to 50s and returns the
|
|
1205
|
+
moment the user types anything, as a tool result. That's the most reliable
|
|
1206
|
+
delivery path across every MCP client (notifications over SSE are silently
|
|
1207
|
+
dropped by Claude Code, Cursor, and others). Loop pattern:
|
|
1208
|
+
while (true) {
|
|
1209
|
+
const r = await wait_for_inbox({ timeout_seconds: 50 });
|
|
1210
|
+
if (r !== "inbox:empty") handle(r);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1049
1213
|
7. If your tool call fails with "MCP server not connected" / "transport
|
|
1050
1214
|
closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
|
|
1051
1215
|
connected to the same server right now. Only YOUR client's transport
|
|
@@ -1194,6 +1358,37 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
1194
1358
|
}],
|
|
1195
1359
|
};
|
|
1196
1360
|
});
|
|
1361
|
+
server.tool("wait_for_inbox", "Block until the user sends an unsolicited message, or until the timeout " +
|
|
1362
|
+
"expires. Returns the message(s) as tool results (the reliable MCP delivery " +
|
|
1363
|
+
"path — notifications over SSE are dropped by many clients). Default timeout " +
|
|
1364
|
+
"is 50s to stay under the JS SDK's 60s request timeout; keep the agent-side " +
|
|
1365
|
+
"loop re-calling on empty so a quiet user doesn't leak an abandoned waiter. " +
|
|
1366
|
+
"If messages are already queued for this session, returns them immediately.", {
|
|
1367
|
+
timeout_seconds: z.number().min(5).max(55).default(50)
|
|
1368
|
+
.describe("How long to block before returning empty (5-55s)"),
|
|
1369
|
+
}, async ({ timeout_seconds = 50 }) => {
|
|
1370
|
+
// Fast-path: if there are already messages queued for this session tag,
|
|
1371
|
+
// drain and return them without parking a waiter.
|
|
1372
|
+
const queued = drainInboxFor(sessionTag);
|
|
1373
|
+
if (queued.length > 0) {
|
|
1374
|
+
const body = queued.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
1375
|
+
return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
|
|
1376
|
+
}
|
|
1377
|
+
const token = randomUUID();
|
|
1378
|
+
const entries = await new Promise((resolve) => {
|
|
1379
|
+
const timer = setTimeout(() => {
|
|
1380
|
+
inboxWaiters.delete(token);
|
|
1381
|
+
resolve([]);
|
|
1382
|
+
}, timeout_seconds * 1000);
|
|
1383
|
+
inboxWaiters.set(token, { resolve, timer, tag: sessionTag });
|
|
1384
|
+
});
|
|
1385
|
+
if (entries.length === 0) {
|
|
1386
|
+
return { content: [{ type: "text", text: "inbox:empty" }] };
|
|
1387
|
+
}
|
|
1388
|
+
log("·", "wait_for_inbox", `${entries.length} message(s) delivered`, clientId);
|
|
1389
|
+
const body = entries.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
1390
|
+
return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
|
|
1391
|
+
});
|
|
1197
1392
|
server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
|
|
1198
1393
|
"Call this periodically during long work as a cheap heartbeat — the server " +
|
|
1199
1394
|
"will piggy-back any pending inbox messages in the response, so you stay " +
|
|
@@ -1232,7 +1427,12 @@ const sessions = {};
|
|
|
1232
1427
|
// list and pills bar accurate even when clients vanish without closing their
|
|
1233
1428
|
// transport (VS Code window closed, laptop lid shut, network died). On next
|
|
1234
1429
|
// reconnect the client gets a 404 and reinitializes cleanly.
|
|
1235
|
-
|
|
1430
|
+
//
|
|
1431
|
+
// The MCP instructions force agents to call get_idle_seconds every 15–30s as a
|
|
1432
|
+
// keepalive, so any session that hasn't made *any* request in 90s is almost
|
|
1433
|
+
// certainly dead. Keep this tight — the whole point is that stale sessions
|
|
1434
|
+
// stop showing up in broadcast acks.
|
|
1435
|
+
const SESSION_IDLE_TIMEOUT_MS = 90 * 1000;
|
|
1236
1436
|
setInterval(() => {
|
|
1237
1437
|
const now = Date.now();
|
|
1238
1438
|
for (const [sessionId, meta] of Object.entries(sessions)) {
|
|
@@ -1246,7 +1446,16 @@ setInterval(() => {
|
|
|
1246
1446
|
delete sessions[sessionId];
|
|
1247
1447
|
}
|
|
1248
1448
|
}
|
|
1249
|
-
|
|
1449
|
+
// Prune SSE inbox subscribers whose underlying socket has died. Node
|
|
1450
|
+
// surfaces dead sockets as destroyed/writableEnded — if we don't sweep
|
|
1451
|
+
// these, broadcastInbox quietly writes to ghosts and the ack count lies
|
|
1452
|
+
// to the user ("Broadcast to 3 sessions" when there's really one).
|
|
1453
|
+
for (const c of inboxStreamClients) {
|
|
1454
|
+
if (c.res.destroyed || c.res.writableEnded || !c.res.writable) {
|
|
1455
|
+
inboxStreamClients.delete(c);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}, 15_000);
|
|
1250
1459
|
function listActiveSessions() {
|
|
1251
1460
|
return Object.values(sessions);
|
|
1252
1461
|
}
|
|
@@ -1255,6 +1464,38 @@ function sessionsMatchingTag(tag) {
|
|
|
1255
1464
|
return listActiveSessions();
|
|
1256
1465
|
return listActiveSessions().filter(s => s.tag === tag);
|
|
1257
1466
|
}
|
|
1467
|
+
// Synchronous best-effort liveness check before we count sessions in an ack.
|
|
1468
|
+
// The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
|
|
1469
|
+
// the response stream of the last GET the client opened — if that stream is
|
|
1470
|
+
// destroyed/ended, the client is gone. We also use the `lastSeen` shortcut:
|
|
1471
|
+
// if a session hasn't made a request in more than (idle+grace) seconds and
|
|
1472
|
+
// the MCP instructions require a 15-30s heartbeat, it's dead. Be lenient —
|
|
1473
|
+
// false-positives here result in lying to the user; false-negatives just
|
|
1474
|
+
// cause a harmless write that the next reap will clean up.
|
|
1475
|
+
const LIVE_GRACE_MS = 60_000;
|
|
1476
|
+
function pruneDeadSessions() {
|
|
1477
|
+
const now = Date.now();
|
|
1478
|
+
for (const [sessionId, meta] of Object.entries(sessions)) {
|
|
1479
|
+
const stale = now - meta.lastSeen > LIVE_GRACE_MS;
|
|
1480
|
+
const transport = httpTransports[sessionId];
|
|
1481
|
+
// The SDK stashes the active response stream on the transport for server-
|
|
1482
|
+
// sent notifications. If it exists and is dead, prune. Guarded because
|
|
1483
|
+
// the internal field name isn't stable across SDK versions.
|
|
1484
|
+
const streams = [transport?._streams, transport?._responseStreams, transport?._sseResponse]
|
|
1485
|
+
.filter(Boolean)
|
|
1486
|
+
.flatMap(s => (s instanceof Map ? [...s.values()] : Array.isArray(s) ? s : [s]));
|
|
1487
|
+
const deadStream = streams.length > 0 && streams.every(r => r?.destroyed || r?.writableEnded || r?.writable === false);
|
|
1488
|
+
if (stale || deadStream) {
|
|
1489
|
+
log("·", "session", `pruned unresponsive session ${meta.clientId} (stale=${stale}, deadStream=${deadStream})`);
|
|
1490
|
+
try {
|
|
1491
|
+
httpTransports[sessionId]?.close();
|
|
1492
|
+
}
|
|
1493
|
+
catch { /* ignore */ }
|
|
1494
|
+
delete httpTransports[sessionId];
|
|
1495
|
+
delete sessions[sessionId];
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1258
1499
|
function sessionDisplay(s) {
|
|
1259
1500
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1260
1501
|
}
|
|
@@ -1323,10 +1564,83 @@ app.all("/mcp", async (req, res) => {
|
|
|
1323
1564
|
}
|
|
1324
1565
|
});
|
|
1325
1566
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
1326
|
-
app.listen(PORT, "0.0.0.0", () => {
|
|
1567
|
+
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
1327
1568
|
const ip = getLocalIp();
|
|
1328
1569
|
console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
|
|
1329
1570
|
console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
|
|
1330
1571
|
startTelegramListener();
|
|
1331
1572
|
open(`http://localhost:${PORT}`).catch(() => { });
|
|
1332
1573
|
});
|
|
1574
|
+
// TCP-level keepalive on every incoming socket. Without this, a client that
|
|
1575
|
+
// vanishes (laptop lid, killed VS Code, WiFi drop) leaves a half-open TCP
|
|
1576
|
+
// connection that Node never notices — the SDK's `onclose` therefore never
|
|
1577
|
+
// fires and the session goes zombie. With SO_KEEPALIVE the OS probes every
|
|
1578
|
+
// 15s and kills the socket within a couple minutes of silence, which fires
|
|
1579
|
+
// our reaper and clears the session bookkeeping.
|
|
1580
|
+
httpServer.on("connection", (socket) => {
|
|
1581
|
+
socket.setKeepAlive(true, 15_000);
|
|
1582
|
+
});
|
|
1583
|
+
// keepAliveTimeout gates how long Node holds an HTTP/1.1 keep-alive idle
|
|
1584
|
+
// connection open before closing it. Default is 5s, which was fine for
|
|
1585
|
+
// short-lived requests but kills long-poll waiters and MCP GET streams
|
|
1586
|
+
// prematurely. Bump above the 55s long-poll ceiling so the socket stays
|
|
1587
|
+
// alive across the whole wait. headersTimeout must exceed it.
|
|
1588
|
+
httpServer.keepAliveTimeout = 75_000;
|
|
1589
|
+
httpServer.headersTimeout = 80_000;
|
|
1590
|
+
// App-level keepalive on every active MCP GET SSE stream. The SDK doesn't
|
|
1591
|
+
// emit any bytes on an idle stream, so intermediate proxies and some clients
|
|
1592
|
+
// time out the stream after ~60s of silence. We write an SSE *comment* line
|
|
1593
|
+
// (`: keepalive\n\n`) directly to each live response — comments are ignored
|
|
1594
|
+
// by SSE parsers but reset proxy idle timers and surface dead sockets as
|
|
1595
|
+
// write errors that we can catch and reap. Pattern is the community-standard
|
|
1596
|
+
// fix for typescript-sdk#270.
|
|
1597
|
+
setInterval(() => {
|
|
1598
|
+
for (const [sid, transport] of Object.entries(httpTransports)) {
|
|
1599
|
+
const t = transport;
|
|
1600
|
+
// Internal field names vary across SDK versions. Collect every candidate
|
|
1601
|
+
// response stream reference; the ones we find are either Response-like
|
|
1602
|
+
// objects with .write or Maps/arrays of them. Write-failure is the signal
|
|
1603
|
+
// that tells us the socket is dead.
|
|
1604
|
+
const candidates = [];
|
|
1605
|
+
for (const key of ["_streamMapping", "_streams", "_responseStreams", "_sseResponse", "_responses"]) {
|
|
1606
|
+
const v = t[key];
|
|
1607
|
+
if (!v)
|
|
1608
|
+
continue;
|
|
1609
|
+
if (v instanceof Map)
|
|
1610
|
+
candidates.push(...v.values());
|
|
1611
|
+
else if (Array.isArray(v))
|
|
1612
|
+
candidates.push(...v);
|
|
1613
|
+
else
|
|
1614
|
+
candidates.push(v);
|
|
1615
|
+
}
|
|
1616
|
+
let wrote = false;
|
|
1617
|
+
let allDead = candidates.length > 0;
|
|
1618
|
+
for (const r of candidates) {
|
|
1619
|
+
if (!r || r.destroyed || r.writableEnded || r.writable === false)
|
|
1620
|
+
continue;
|
|
1621
|
+
try {
|
|
1622
|
+
r.write(`: keepalive ${Date.now()}\n\n`);
|
|
1623
|
+
wrote = true;
|
|
1624
|
+
allDead = false;
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
// write failed — socket is dead, move on
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
if (candidates.length > 0 && allDead) {
|
|
1631
|
+
try {
|
|
1632
|
+
httpTransports[sid]?.close();
|
|
1633
|
+
}
|
|
1634
|
+
catch { /* ignore */ }
|
|
1635
|
+
delete httpTransports[sid];
|
|
1636
|
+
delete sessions[sid];
|
|
1637
|
+
}
|
|
1638
|
+
// Touch lastSeen on a successful keepalive write so the reaper doesn't
|
|
1639
|
+
// kill a session that's quietly connected but idle. lastSeen normally
|
|
1640
|
+
// tracks inbound requests; extending it to "stream is verified writable"
|
|
1641
|
+
// is fine — if the write succeeds, the client really is still there.
|
|
1642
|
+
if (wrote && sessions[sid]) {
|
|
1643
|
+
sessions[sid].lastSeen = Date.now();
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}, 20_000);
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"omni-notify-mcp": "dist/index.js"
|
|
8
|
+
"omni-notify-mcp": "dist/index.js",
|
|
9
|
+
"omni-notify-ui": "dist/ui/server.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"dist/",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"build:ui": "tsc -p ui/tsconfig.json",
|
|
48
49
|
"start": "node dist/index.js",
|
|
49
50
|
"ui": "npm run build:ui && node dist/ui/server.js",
|
|
51
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
50
52
|
"prepublishOnly": "npm run build:mcp"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|