omni-notify-mcp 1.3.30 → 1.3.34
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 +7 -5
- package/dist/index.js +206 -82
- package/dist/ui/server.js +482 -149
- package/package.json +1 -1
- package/ui/public/app.js +193 -46
- package/ui/public/style.css +3 -0
package/README.md
CHANGED
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
|
|
29
29
|
| Process | Entry | What it owns |
|
|
30
30
|
|---|---|---|
|
|
31
|
-
| **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on
|
|
31
|
+
| **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on port 3737 on every interface, IPv4 and IPv6, by default. |
|
|
32
32
|
| **stdio MCP bridge** | `dist/index.js` (bin `omni-notify-mcp`) | A stateless proxy. Auto-spawns the server if `:3737` isn't answering, opens one persistent `/mcp` session, forwards every tool call to it, subscribes to `/api/inbox/stream` and re-emits each message as a `notifications/claude/channel` event. Holds no config and no queue. |
|
|
33
33
|
|
|
34
34
|
The VS Code extension (`vscode-extension/`) is a third, optional face: it starts the
|
|
35
|
-
server if needed
|
|
35
|
+
server if needed, renders `http://localhost:3737/` inside a webview, reports on its status
|
|
36
|
+
bar whether the server can actually deliver, and writes the stdio bridge into both MCP
|
|
37
|
+
host config files (`~/.claude.json` and the open folder's `.vscode/mcp.json`).
|
|
36
38
|
|
|
37
39
|
> **Build note.** `package.json` is deliberately untracked in this repo (see
|
|
38
40
|
> `.gitignore`), so the dependency list, the `bin` map, the version and the extension
|
|
@@ -77,7 +79,7 @@ entirely server-side.
|
|
|
77
79
|
| `ask` | `question`, `timeout_seconds` (30–3600, default 300) | Sends the question to Telegram (every configured chat id) and email (with a one-shot `/reply/:token` web page), then **blocks** until a reply lands or the timeout throws. |
|
|
78
80
|
| `poll` | — | Drains queued inbox messages for this session's tag; returns `inbox:empty` when there are none. |
|
|
79
81
|
| `wait_for_inbox` | `timeout_seconds` (5–55, default 50) | Parks a waiter and returns the moment a matching message arrives, as a tool *result*. Returns already-queued messages immediately without parking. |
|
|
80
|
-
| `get_idle_seconds` | — |
|
|
82
|
+
| `get_idle_seconds` | — | How long since the last OS keyboard/mouse input, as one of two DIFFERENT answers: `idle: measured N seconds …`, or `idle: could not measure — user activity unknown, so activity gating is skipped` when this machine cannot report user activity at all. Drains the inbox as a side effect. |
|
|
81
83
|
| `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. Drains the inbox. |
|
|
82
84
|
| `get_dnd_status` | — | `{ active, reason }` where reason is `disabled` (master mute or this client disabled), `manual`, `schedule` or `off`. Drains the inbox. |
|
|
83
85
|
| `update_instructions` | `instructions` (≤4000), `target` = `global`\|`project` | Rewrites a marker-delimited block in `~/.claude/CLAUDE.md` (global) or `./.claude/CLAUDE.md` (project). |
|
|
@@ -273,7 +275,7 @@ rename, disable or force-reconnect one. `help.html` is the copy-paste setup page
|
|
|
273
275
|
| `POST /api/slack/events` | Slack Events API receiver; HMAC-verified when `SLACK_SIGNING_SECRET` is set. |
|
|
274
276
|
| `POST /api/ui/visibility` | The web UI heartbeats its own visibility; while visible, non-`high` notifications stay desktop-only. |
|
|
275
277
|
| `GET /reply/:token`, `POST /reply/:token` | One-shot web reply page for `ask` over email. |
|
|
276
|
-
| `GET /v1/health`, `/v1/info` |
|
|
278
|
+
| `GET /v1/health`, `/v1/info` | Delivery report read by the VS Code extension's status bar: `{healthy, process, mcp, channels, reason}`. `healthy` is true only when the MCP endpoint is enabled **and** at least one channel is enabled and able to carry, so a listening server that could send nothing is reported unhealthy rather than green. `/v1/info` stays a version stub. |
|
|
277
279
|
| `/:topic/sse`, `/:topic/json`, `PUT`/`POST /:topic`, `/:topic/subscribers` | Built-in ntfy protocol server (also mounted under `/ntfy/…`). |
|
|
278
280
|
|
|
279
281
|
`POST /__test__/inject-inbox` and `GET /__test__/slack-clients` exist only when
|
|
@@ -298,7 +300,7 @@ rename, disable or force-reconnect one. `help.html` is the copy-paste setup page
|
|
|
298
300
|
| Script | What it does |
|
|
299
301
|
|---|---|
|
|
300
302
|
| `notify-ui.sh` | Build if `dist/ui/server.js` is missing, then run the server in the foreground (**without** `ENABLE_MCP`). |
|
|
301
|
-
| `restart.sh` | `npm run build:ui`,
|
|
303
|
+
| `restart.sh` | `npm run build:ui`, stop the one pid holding `:3737`, relaunch detached with `ENABLE_MCP=1` to `/tmp/notify-mcp.log`, then read `/v1/health` and exit non-zero unless the server reports it can deliver. |
|
|
302
304
|
| `bus-up.sh` | Singleton watchdog: keeps `:3737` listening, redeploys when `dist/ui/server.js` changes on disk, and keeps one `notify-watch.sh` alive. Logs to `.run/`. |
|
|
303
305
|
| `notify-watch.sh` | Detached responder. Long-polls `/api/agent/inbox/wait` for `<host>-<folder>-bot`, and passes every message **verbatim** to a headless `claude -p` that answers via `POST /api/agent/slack/reply`. |
|
|
304
306
|
| `scripts/agent-notify.sh`, `agent-poll-inbox.sh`, `agent-wait-inbox.sh`, `agent-listen-loop.sh` | curl wrappers over the plain-HTTP agent API (`NOTIFY_BASE_URL`, `NOTIFY_AGENT_KEY`, `NOTIFY_TAG`, `NOTIFY_ON_MESSAGE_CMD`). |
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* listener) lives in the long-running HTTP server (`ui/server.js`, default
|
|
8
8
|
* port 3737). The bridge:
|
|
9
9
|
*
|
|
10
|
-
* 1. Ensures the HTTP server is running
|
|
10
|
+
* 1. Ensures the HTTP server is running, and auto-spawns it detached when a
|
|
11
|
+
* server ships beside this bridge — the npm package and this repo both put
|
|
12
|
+
* `ui/server.js` there. A bridge-only copy (the VS Code extension mirrors
|
|
13
|
+
* `dist/index.mjs` alone, deliberately: see vscode-extension/esbuild.js)
|
|
14
|
+
* has no server to start, so it reports that instead of spawning one.
|
|
11
15
|
* 2. Subscribes to /api/inbox/stream via SSE and re-emits each unsolicited
|
|
12
16
|
* user message as a `notifications/claude/channel` notification to the
|
|
13
17
|
* attached client. Claude Code (v2.1.80+) surfaces those as synthetic
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
32
|
import { spawn } from "child_process";
|
|
29
|
-
import { existsSync } from "fs";
|
|
33
|
+
import { existsSync, readFileSync } from "fs";
|
|
30
34
|
import { join, dirname, basename } from "path";
|
|
31
35
|
import { hostname } from "os";
|
|
32
36
|
import { fileURLToPath } from "url";
|
|
@@ -35,6 +39,16 @@ import { splitForNotify } from "./chunk.js";
|
|
|
35
39
|
const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
|
|
36
40
|
const BASE = `http://localhost:${PORT}`;
|
|
37
41
|
const CLEAN_ID = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
42
|
+
const BRIDGE_VERSION = (() => {
|
|
43
|
+
const manifest = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
44
|
+
// An absent manifest is a legitimate state for a bridge-only copy and renders
|
|
45
|
+
// `unversioned`; a manifest present but unreadable throws.
|
|
46
|
+
if (!existsSync(manifest)) {
|
|
47
|
+
stderr(`[bridge] no manifest at ${manifest}, so this copy cannot name the build it is — a bridge-only copy has nothing to read. Install the server package, or start the server another way; the bridge still works, it just cannot say which version it is.`);
|
|
48
|
+
return "unversioned";
|
|
49
|
+
}
|
|
50
|
+
return JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
51
|
+
})();
|
|
38
52
|
// NOTIFY_MCP_TAG is the explicit per-window name; otherwise derive from the
|
|
39
53
|
// workspace Claude Code passes (CLAUDE_PROJECT_DIR) — falling back to the launch
|
|
40
54
|
// dir — and walk up to the nearest meaningful folder, skipping generic launcher/
|
|
@@ -120,16 +134,18 @@ function spawnUiServerIfNeeded() {
|
|
|
120
134
|
];
|
|
121
135
|
const uiPath = candidates.find(p => existsSync(p));
|
|
122
136
|
if (!uiPath) {
|
|
123
|
-
stderr(`[bridge]
|
|
124
|
-
return;
|
|
137
|
+
stderr(`[bridge] no ui/server.js beside this bridge at ${here}, so there is nothing here to spawn — a bridge-only copy (the VS Code extension mirrors the bridge alone). Start a server with 'npx -y omni-notify-mcp' or the extension's Start Server command, or install the package so the server ships beside the bridge.`);
|
|
138
|
+
return false;
|
|
125
139
|
}
|
|
126
140
|
stderr(`[bridge] auto-spawning UI server: ${uiPath}`);
|
|
127
141
|
const child = spawn(process.execPath, [uiPath], {
|
|
128
142
|
detached: true,
|
|
129
143
|
stdio: "ignore",
|
|
144
|
+
windowsHide: true,
|
|
130
145
|
env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
|
|
131
146
|
});
|
|
132
147
|
child.unref();
|
|
148
|
+
return true;
|
|
133
149
|
}
|
|
134
150
|
async function waitForServer(maxMs = 15_000) {
|
|
135
151
|
const deadline = Date.now() + maxMs;
|
|
@@ -147,6 +163,16 @@ function stderr(line) {
|
|
|
147
163
|
}
|
|
148
164
|
catch { /* ignore */ }
|
|
149
165
|
}
|
|
166
|
+
// A transport failure must NAME THE HOP — `fetch` reports a bare "fetch failed" with no host, port or cause, which is the exact string that hid a dead channel for ten minutes (#752). Do not simplify this back to `throw err`.
|
|
167
|
+
function unreachable(url, err) {
|
|
168
|
+
const cause = err?.cause;
|
|
169
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
170
|
+
? String(cause.code)
|
|
171
|
+
: undefined;
|
|
172
|
+
const why = code === "ECONNREFUSED" ? `nothing is listening on ${new URL(url).host}`
|
|
173
|
+
: code ?? (err instanceof Error ? err.message : String(err));
|
|
174
|
+
return new Error(`${url} is unreachable: ${why}`, { cause: err });
|
|
175
|
+
}
|
|
150
176
|
// ── 2. HTTP /mcp session — the bridge itself is an MCP client of the server ──
|
|
151
177
|
// We run a single persistent MCP-over-HTTP session per bridge process and
|
|
152
178
|
// forward every local stdio tool call through it. Using one shared session
|
|
@@ -154,68 +180,122 @@ function stderr(line) {
|
|
|
154
180
|
// draining all consistent with what the HTTP server sees.
|
|
155
181
|
let httpSessionId;
|
|
156
182
|
let httpRpcId = 1;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// caller says so) carry no `id` and get no response. Spec-compliant servers
|
|
160
|
-
// return 202 Accepted with empty body.
|
|
161
|
-
const notif = isNotification || method.startsWith("notifications/");
|
|
162
|
-
const body = { jsonrpc: "2.0", method, params: params ?? {} };
|
|
163
|
-
if (!notif)
|
|
164
|
-
body.id = httpRpcId++;
|
|
183
|
+
let handshake;
|
|
184
|
+
async function postToMcp(body, sid) {
|
|
165
185
|
const headers = {
|
|
166
186
|
"Content-Type": "application/json",
|
|
167
187
|
"Accept": "application/json, text/event-stream",
|
|
168
188
|
};
|
|
169
|
-
if (
|
|
170
|
-
headers["mcp-session-id"] =
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
if (sid)
|
|
190
|
+
headers["mcp-session-id"] = sid;
|
|
191
|
+
const url = `${BASE}/mcp${MCP_QUERY}`;
|
|
192
|
+
let r;
|
|
193
|
+
try {
|
|
194
|
+
r = await fetch(url, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers,
|
|
197
|
+
body: JSON.stringify(body),
|
|
198
|
+
// Long-poll tools may block up to ~55s server-side. Give the fetch
|
|
199
|
+
// generous headroom but not infinite, so a wedged server surfaces fast.
|
|
200
|
+
signal: AbortSignal.timeout(120_000),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw unreachable(url, err);
|
|
185
205
|
}
|
|
186
206
|
if (r.status >= 500) {
|
|
187
207
|
throw new Error(`HTTP ${r.status} from /mcp: ${await r.text().catch(() => "")}`);
|
|
188
208
|
}
|
|
189
|
-
const sid = r.headers.get("mcp-session-id");
|
|
190
|
-
if (sid && !httpSessionId)
|
|
191
|
-
httpSessionId = sid;
|
|
192
|
-
if (notif)
|
|
193
|
-
return undefined;
|
|
194
|
-
const ctype = r.headers.get("content-type") ?? "";
|
|
195
209
|
const raw = await r.text();
|
|
210
|
+
const answer = { status: r.status, sessionId: r.headers.get("mcp-session-id") ?? undefined, raw };
|
|
211
|
+
if (!raw)
|
|
212
|
+
return answer;
|
|
213
|
+
const ctype = r.headers.get("content-type") ?? "";
|
|
196
214
|
if (ctype.includes("application/json")) {
|
|
197
|
-
|
|
215
|
+
answer.json = JSON.parse(raw);
|
|
216
|
+
return answer;
|
|
198
217
|
}
|
|
199
218
|
// SSE framing: pull the first data: line as the JSON-RPC response.
|
|
200
219
|
for (const line of raw.split(/\r?\n/)) {
|
|
201
220
|
if (line.startsWith("data:")) {
|
|
202
221
|
const json = line.slice(5).trim();
|
|
203
|
-
if (json)
|
|
204
|
-
|
|
222
|
+
if (json) {
|
|
223
|
+
answer.json = JSON.parse(json);
|
|
224
|
+
return answer;
|
|
225
|
+
}
|
|
205
226
|
}
|
|
206
227
|
}
|
|
207
|
-
|
|
228
|
+
return answer;
|
|
208
229
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
230
|
+
function sessionWasRefused(answer) {
|
|
231
|
+
return answer.status === 404 || (answer.status === 400 && answer.json?.error?.code === -32000);
|
|
232
|
+
}
|
|
233
|
+
function sessionRefusal(answer) {
|
|
234
|
+
if (answer.json?.error === "mcp_disabled") {
|
|
235
|
+
return new Error(`${BASE}/mcp cannot carry a message: the server has its MCP endpoint DISABLED (start it with ENABLE_MCP=1)`);
|
|
236
|
+
}
|
|
237
|
+
const said = answer.json?.error ? JSON.stringify(answer.json.error) : answer.raw.slice(0, 200);
|
|
238
|
+
return new Error(`${BASE}/mcp refused the session (HTTP ${answer.status}): ${said}`);
|
|
239
|
+
}
|
|
240
|
+
async function liveSession() {
|
|
241
|
+
const current = httpSessionId;
|
|
242
|
+
if (current)
|
|
243
|
+
return current;
|
|
244
|
+
handshake ??= openSession();
|
|
245
|
+
return handshake;
|
|
246
|
+
}
|
|
247
|
+
async function openSession() {
|
|
248
|
+
try {
|
|
249
|
+
const res = await postToMcp({
|
|
250
|
+
jsonrpc: "2.0",
|
|
251
|
+
id: httpRpcId++,
|
|
252
|
+
method: "initialize",
|
|
253
|
+
params: {
|
|
254
|
+
protocolVersion: "2024-11-05",
|
|
255
|
+
capabilities: {},
|
|
256
|
+
clientInfo: { name: CLIENT_NAME, version: "1.0" },
|
|
257
|
+
},
|
|
258
|
+
}, undefined);
|
|
259
|
+
if (res.json?.error)
|
|
260
|
+
throw sessionRefusal(res);
|
|
261
|
+
const sid = res.sessionId;
|
|
262
|
+
if (!sid)
|
|
263
|
+
throw new Error(`/mcp answered initialize with no session id (HTTP ${res.status}): ${res.raw.slice(0, 200)}`);
|
|
264
|
+
httpSessionId = sid;
|
|
265
|
+
// Follow-up: the spec requires a notifications/initialized after initialize.
|
|
266
|
+
await postToMcp({ jsonrpc: "2.0", method: "notifications/initialized" }, sid);
|
|
267
|
+
return sid;
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
handshake = undefined;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function httpRpc(method, params, isNotification = false) {
|
|
274
|
+
// JSON-RPC notifications (method name starts with `notifications/` or the
|
|
275
|
+
// caller says so) carry no `id` and get no response. Spec-compliant servers
|
|
276
|
+
// return 202 Accepted with empty body.
|
|
277
|
+
const notif = isNotification || method.startsWith("notifications/");
|
|
278
|
+
return sendOverSession(method, params, notif, false);
|
|
279
|
+
}
|
|
280
|
+
async function sendOverSession(method, params, notif, rehandshaken) {
|
|
281
|
+
const sid = await liveSession();
|
|
282
|
+
const body = { jsonrpc: "2.0", method, params: params ?? {} };
|
|
283
|
+
if (!notif)
|
|
284
|
+
body.id = httpRpcId++;
|
|
285
|
+
const answer = await postToMcp(body, sid);
|
|
286
|
+
if (sessionWasRefused(answer)) {
|
|
287
|
+
if (httpSessionId === sid)
|
|
288
|
+
httpSessionId = undefined;
|
|
289
|
+
if (rehandshaken) {
|
|
290
|
+
throw sessionRefusal(answer);
|
|
291
|
+
}
|
|
292
|
+
return sendOverSession(method, params, notif, true);
|
|
293
|
+
}
|
|
294
|
+
if (notif)
|
|
295
|
+
return undefined;
|
|
296
|
+
if (!answer.json)
|
|
297
|
+
throw new Error(`unexpected response from /mcp: ${answer.raw.slice(0, 200)}`);
|
|
298
|
+
return answer.json;
|
|
219
299
|
}
|
|
220
300
|
// Periodically touch our /mcp session so the server's reaper doesn't prune it.
|
|
221
301
|
// Without this, the bridge opens a session on startup, proxies tool calls
|
|
@@ -233,7 +313,7 @@ function startSessionKeepalive() {
|
|
|
233
313
|
}, 30_000);
|
|
234
314
|
}
|
|
235
315
|
// ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
|
|
236
|
-
const server = new McpServer({ name: "notify-mcp", version:
|
|
316
|
+
const server = new McpServer({ name: "notify-mcp", version: BRIDGE_VERSION }, {
|
|
237
317
|
// Declare the claude/channel capability so Claude Code knows to surface
|
|
238
318
|
// our notifications/claude/channel events as synthetic user turns. The
|
|
239
319
|
// `reply` tool below is how Claude hands the agent's response back to us.
|
|
@@ -291,17 +371,52 @@ async function proxyToolCall(name, args) {
|
|
|
291
371
|
}
|
|
292
372
|
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
293
373
|
}
|
|
374
|
+
async function deliverOverAgentApi(message, priority, mcpFailure) {
|
|
375
|
+
const headers = { "Content-Type": "application/json" };
|
|
376
|
+
const key = (process.env.NOTIFY_AGENT_KEY ?? "").trim();
|
|
377
|
+
if (key)
|
|
378
|
+
headers["x-notify-key"] = key;
|
|
379
|
+
const tagPrefix = SESSION_TAG ? `[${SESSION_TAG}] ` : "";
|
|
380
|
+
const agentUrl = `${BASE}/api/agent/notify`;
|
|
381
|
+
const failed = (detail) => ({
|
|
382
|
+
content: [{ type: "text", text: `NOT DELIVERED — outcome=failed; neither door carried it: ${mcpFailure}; ${detail}` }],
|
|
383
|
+
isError: true,
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
const r = await fetch(agentUrl, {
|
|
387
|
+
method: "POST",
|
|
388
|
+
headers,
|
|
389
|
+
body: JSON.stringify({ message: `${tagPrefix}${message}`, priority }),
|
|
390
|
+
signal: AbortSignal.timeout(120_000),
|
|
391
|
+
});
|
|
392
|
+
const out = await r.json().catch(() => undefined);
|
|
393
|
+
if (out?.outcome !== "delivered") {
|
|
394
|
+
return failed(`${agentUrl} answered HTTP ${r.status} ${JSON.stringify(out ?? {})}`);
|
|
395
|
+
}
|
|
396
|
+
stderr(`[bridge] /mcp could not carry a notify (${mcpFailure}); delivered over /api/agent/notify instead`);
|
|
397
|
+
return {
|
|
398
|
+
content: [{
|
|
399
|
+
type: "text",
|
|
400
|
+
text: `outcome=delivered attempted=[${(out.attempted ?? []).join(", ")}] delivered=[${(out.delivered ?? []).join(", ")}]`,
|
|
401
|
+
}],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return failed(unreachable(agentUrl, err).message);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
294
408
|
async function sendNotifyChunked(message, priority) {
|
|
295
409
|
const chunks = splitForNotify(message);
|
|
296
410
|
if (chunks.length === 1)
|
|
297
411
|
return proxyToolCall("notify", { message, priority });
|
|
412
|
+
// Every chunk is sent, and the ANSWER is composed from all of them — a chunk
|
|
413
|
+
// that reached nobody must not short-circuit the report into one chunk's text,
|
|
414
|
+
// losing how many of how many failed (#774: the server now flags such a result
|
|
415
|
+
// as an error itself, so a short-circuit here would swallow the summary).
|
|
298
416
|
const results = [];
|
|
299
417
|
for (const chunk of chunks) {
|
|
300
418
|
results.push(await proxyToolCall("notify", { message: chunk, priority }));
|
|
301
419
|
}
|
|
302
|
-
const failed = results.find(r => r.isError);
|
|
303
|
-
if (failed)
|
|
304
|
-
return failed;
|
|
305
420
|
const texts = results.map(r => r.content?.[0]?.text ?? "");
|
|
306
421
|
const inboxMarker = "⚠️ USER SENT YOU A MESSAGE";
|
|
307
422
|
const inboxBlocks = texts
|
|
@@ -343,7 +458,15 @@ server.tool("notify", "Send a notification to the user. Delivery channels and DN
|
|
|
343
458
|
"delivered in order. Never truncate to fit a part.", {
|
|
344
459
|
message: z.string().max(5000),
|
|
345
460
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
346
|
-
}, async ({ message, priority }) =>
|
|
461
|
+
}, async ({ message, priority }) => {
|
|
462
|
+
try {
|
|
463
|
+
return await sendNotifyChunked(message, priority);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
// OWNER: a notification the user never sees is the whole defect this tool exists to prevent, so a /mcp transport failure must not end the send — the same server's /api/agent/notify door carries no session and finishes it, and the server's own log stream records the outcome either way.
|
|
467
|
+
return deliverOverAgentApi(message, priority, err instanceof Error ? err.message : String(err));
|
|
468
|
+
}
|
|
469
|
+
});
|
|
347
470
|
server.tool("ask", "Send a question to the user and wait for their reply.", {
|
|
348
471
|
question: z.string().max(500),
|
|
349
472
|
timeout_seconds: z.number().min(30).max(3600).default(300),
|
|
@@ -379,7 +502,13 @@ server.tool("reply", "Reply to the user's most recent channel message. Routes th
|
|
|
379
502
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
380
503
|
}, async ({ message, priority }) => {
|
|
381
504
|
const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
|
|
382
|
-
|
|
505
|
+
try {
|
|
506
|
+
return await sendNotifyChunked(`${tagPrefix}${message}`, priority);
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
// OWNER: the user is waiting on this reply over the channel they are reading, so a /mcp transport failure must not end it — the same server's /api/agent/notify door carries no session and finishes it, and the server's own log stream records the outcome either way.
|
|
510
|
+
return deliverOverAgentApi(message, priority, err instanceof Error ? err.message : String(err));
|
|
511
|
+
}
|
|
383
512
|
});
|
|
384
513
|
async function subscribeInbox() {
|
|
385
514
|
const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
|
|
@@ -413,12 +542,20 @@ async function subscribeInbox() {
|
|
|
413
542
|
const payload = line.slice(5).trim();
|
|
414
543
|
if (!payload)
|
|
415
544
|
continue;
|
|
545
|
+
let entry;
|
|
416
546
|
try {
|
|
417
|
-
|
|
418
|
-
await emitChannelEvent(entry);
|
|
547
|
+
entry = JSON.parse(payload);
|
|
419
548
|
}
|
|
420
549
|
catch (err) {
|
|
421
550
|
stderr(`[bridge] bad SSE payload: ${err instanceof Error ? err.message : String(err)}`);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
// OWNER: ITER/LOOP — one frame that cannot be emitted must not end the stream, so this frame reports itself with its own cause and the next one is read.
|
|
554
|
+
try {
|
|
555
|
+
await emitChannelEvent(entry);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
stderr(`[bridge] channel notification NOT DELIVERED: ${err instanceof Error ? err.message : String(err)}`);
|
|
422
559
|
}
|
|
423
560
|
}
|
|
424
561
|
}
|
|
@@ -432,25 +569,15 @@ async function subscribeInbox() {
|
|
|
432
569
|
}
|
|
433
570
|
}
|
|
434
571
|
async function emitChannelEvent(entry) {
|
|
435
|
-
// Emit the new Claude Code Channels notification
|
|
436
|
-
// `notifications/message` as a belt-and-suspenders approach: hosts that
|
|
437
|
-
// ignore `notifications/claude/channel` may still surface the message as
|
|
438
|
-
// a log line. Both are fire-and-forget; a failure just means the peer
|
|
439
|
-
// closed the stdio transport (we'll notice on the next tool call).
|
|
572
|
+
// Emit the new Claude Code Channels notification; `wait_for_inbox` remains the universal fallback for a host that does not carry it.
|
|
440
573
|
const content = entry.tag ? `[@${entry.tag}] ${entry.text}` : entry.text;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
// client doesn't support the experimental capability — that's fine,
|
|
452
|
-
// wait_for_inbox is the universal fallback.
|
|
453
|
-
}
|
|
574
|
+
await server.server.notification({
|
|
575
|
+
method: "notifications/claude/channel",
|
|
576
|
+
params: {
|
|
577
|
+
content,
|
|
578
|
+
meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
|
|
579
|
+
},
|
|
580
|
+
});
|
|
454
581
|
}
|
|
455
582
|
// ── 5. Wire it up ────────────────────────────────────────────────────────────
|
|
456
583
|
// When the parent (claude.exe / Cursor / Codex) dies, the OS closes our stdin
|
|
@@ -480,17 +607,14 @@ async function shutdownOnPeerLoss(why) {
|
|
|
480
607
|
}
|
|
481
608
|
async function main() {
|
|
482
609
|
if (!(await serverIsUp())) {
|
|
483
|
-
spawnUiServerIfNeeded();
|
|
610
|
+
const spawned = spawnUiServerIfNeeded();
|
|
484
611
|
if (!(await waitForServer())) {
|
|
485
|
-
stderr(
|
|
612
|
+
stderr(spawned
|
|
613
|
+
? `[bridge] HTTP server at ${BASE} did not come up within 15s of being spawned — giving up on push; tool calls will fail.`
|
|
614
|
+
: `[bridge] no HTTP server at ${BASE} and none was spawned (see above) — giving up on push; tool calls will fail.`);
|
|
486
615
|
}
|
|
487
616
|
}
|
|
488
|
-
|
|
489
|
-
await httpInitialize();
|
|
490
|
-
}
|
|
491
|
-
catch (err) {
|
|
492
|
-
stderr(`[bridge] initial HTTP initialize failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
493
|
-
}
|
|
617
|
+
await liveSession().catch(err => stderr(`[bridge] initial /mcp handshake failed: ${err instanceof Error ? err.message : String(err)} — the next tool call handshakes again`));
|
|
494
618
|
// Fire-and-forget: the stdio transport should be usable immediately; the
|
|
495
619
|
// push channel attaches as soon as the SSE handshake completes.
|
|
496
620
|
subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
|