omni-notify-mcp 1.3.14 → 1.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -205
- package/dist/index.js +62 -37
- package/dist/ui/messaging/notificationEngine.js +168 -43
- package/dist/ui/server.js +376 -276
- package/package.json +4 -27
- package/scripts/idle-check.ps1 +47 -0
- package/ui/public/index.html +538 -538
- package/ui/public/style.css +793 -794
- package/dist/channels/desktop.js +0 -48
- package/dist/channels/discord.js +0 -20
- package/dist/channels/email.js +0 -29
- package/dist/channels/ntfy.js +0 -22
- package/dist/channels/pushover.js +0 -26
- package/dist/channels/slack.js +0 -36
- package/dist/channels/sms.js +0 -27
- package/dist/channels/teams.js +0 -43
- package/dist/channels/telegram.js +0 -19
- package/dist/channels/whatsapp.js +0 -11
- package/dist/config.js +0 -16
- package/dist/ui/messaging/types.js +0 -1
package/dist/ui/server.js
CHANGED
|
@@ -12,16 +12,15 @@ import notifier from "node-notifier";
|
|
|
12
12
|
import nodemailer from "nodemailer";
|
|
13
13
|
import { PinpointSMSVoiceV2Client, SendTextMessageCommand, DescribePhoneNumbersCommand, DescribeVerifiedDestinationNumbersCommand } from "@aws-sdk/client-pinpoint-sms-voice-v2";
|
|
14
14
|
import { tmpdir } from "os";
|
|
15
|
-
import { sendWithRouting } from "./messaging/notificationEngine.js";
|
|
15
|
+
import { sendWithRouting, idleReading, formatIdleReading, describePriorityRouting } from "./messaging/notificationEngine.js";
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
18
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
19
19
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
|
|
20
20
|
const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
|
|
21
21
|
const SLACK_REDIRECT_URI = `http://localhost:${PORT}/auth/slack/callback`;
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
const SLACK_OAUTH_SCOPES = "channels:read,groups:read,chat:write,chat:write.public";
|
|
22
|
+
// GATE: the Slack methods a one-click bot token may call — conversations.list (channel picker), conversations.history (bus poller), chat.postMessage (notifications, public channels without an invite); a method outside this set answers missing_scope
|
|
23
|
+
const SLACK_OAUTH_SCOPES = "channels:read,groups:read,channels:history,groups:history,chat:write,chat:write.public";
|
|
25
24
|
const DISCORD_REDIRECT_URI = `http://localhost:${PORT}/auth/discord/callback`;
|
|
26
25
|
// webhook.incoming makes Discord return a ready-to-use channel webhook on Authorize.
|
|
27
26
|
const DISCORD_OAUTH_SCOPE = "webhook.incoming";
|
|
@@ -38,7 +37,6 @@ function defaultConfig() {
|
|
|
38
37
|
disabledClients: [],
|
|
39
38
|
desktop: { enabled: false, sound: true },
|
|
40
39
|
telegram: { enabled: false, token: "", chatIds: [] },
|
|
41
|
-
whatsapp: { enabled: false, instanceId: "", apiToken: "", phone: "" },
|
|
42
40
|
sms: { enabled: false, accessKeyId: "", secretAccessKey: "", region: "us-east-1", originationNumber: "", to: [] },
|
|
43
41
|
email: { enabled: false, to: "" },
|
|
44
42
|
ntfy: { enabled: false, topic: "", serverUrl: "" },
|
|
@@ -140,6 +138,15 @@ function displayTag(tag) {
|
|
|
140
138
|
return clientAliasMap()[tag] || tag;
|
|
141
139
|
}
|
|
142
140
|
const MASKED = "••••••••";
|
|
141
|
+
// Credentials and secrets are runtime state, never source-relative: under a
|
|
142
|
+
// global/`npx` install the compiled server lives in a package directory that
|
|
143
|
+
// contains no secrets file at all, so the file MUST be found by the same
|
|
144
|
+
// CONFIG_DIR resolution the config uses. `NOTIFY_MCP_SECRETS_PATH` overrides.
|
|
145
|
+
const SECRETS_PATH = process.env.NOTIFY_MCP_SECRETS_PATH
|
|
146
|
+
|| join(CONFIG_DIR, "notify-secrets.json");
|
|
147
|
+
// Backward compatibility for an existing per-user install: older builds,
|
|
148
|
+
// `setup-secrets.sh` and the README all placed the file at `~/.notify-mcp-secrets`.
|
|
149
|
+
const LEGACY_SECRETS_PATH = join(homedir(), ".notify-mcp-secrets");
|
|
143
150
|
// AWS SendTextMessage requires E.164 (e.g. +14089812202) — strip spaces, dashes,
|
|
144
151
|
// parens, dots so a user-entered "+1 408 981 2202" is accepted.
|
|
145
152
|
function e164(s) {
|
|
@@ -159,8 +166,6 @@ function maskSecrets(config) {
|
|
|
159
166
|
c.sms.secretAccessKey = MASKED;
|
|
160
167
|
if (c.telegram?.token)
|
|
161
168
|
c.telegram.token = MASKED;
|
|
162
|
-
if (c.whatsapp?.apiToken)
|
|
163
|
-
c.whatsapp.apiToken = MASKED;
|
|
164
169
|
if (c.discord?.webhookUrl)
|
|
165
170
|
c.discord.webhookUrl = MASKED;
|
|
166
171
|
if (c.discord?.clientSecret)
|
|
@@ -173,11 +178,12 @@ function maskSecrets(config) {
|
|
|
173
178
|
c.slack.clientSecret = MASKED;
|
|
174
179
|
if (c.teams?.webhookUrl)
|
|
175
180
|
c.teams.webhookUrl = MASKED;
|
|
181
|
+
delete c.whatsapp;
|
|
176
182
|
return c;
|
|
177
183
|
}
|
|
178
184
|
function mergePreservingSecrets(existing, update) {
|
|
179
185
|
const merged = { ...defaultConfig(), ...existing };
|
|
180
|
-
for (const section of ["desktop", "telegram", "
|
|
186
|
+
for (const section of ["desktop", "telegram", "sms", "email", "ntfy", "discord", "slack", "teams", "dnd", "idle"]) {
|
|
181
187
|
merged[section] = { ...(merged[section] || {}), ...(update[section] || {}) };
|
|
182
188
|
}
|
|
183
189
|
// Nested schedule inside dnd
|
|
@@ -198,7 +204,6 @@ function mergePreservingSecrets(existing, update) {
|
|
|
198
204
|
guard(["email", "accessToken"]);
|
|
199
205
|
guard(["sms", "secretAccessKey"]);
|
|
200
206
|
guard(["telegram", "token"]);
|
|
201
|
-
guard(["whatsapp", "apiToken"]);
|
|
202
207
|
guard(["discord", "webhookUrl"]);
|
|
203
208
|
guard(["discord", "clientSecret"]);
|
|
204
209
|
guard(["slack", "webhookUrl"]);
|
|
@@ -605,27 +610,6 @@ app.get("/api/telegram/chats", async (req, res) => {
|
|
|
605
610
|
res.status(500).json({ error: String(err) });
|
|
606
611
|
}
|
|
607
612
|
});
|
|
608
|
-
app.post("/api/test/whatsapp", async (_req, res) => {
|
|
609
|
-
const config = loadConfig();
|
|
610
|
-
const { instanceId, apiToken, phone } = config.whatsapp ?? {};
|
|
611
|
-
if (!instanceId || !apiToken || !phone) {
|
|
612
|
-
res.status(400).json({ error: "Instance ID, API token and phone are required." });
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
try {
|
|
616
|
-
const r = await fetch(`https://api.green-api.com/waInstance${instanceId}/sendMessage/${apiToken}`, {
|
|
617
|
-
method: "POST",
|
|
618
|
-
headers: { "Content-Type": "application/json" },
|
|
619
|
-
body: JSON.stringify({ chatId: `${phone}@c.us`, message: "Test from Claude Notify — WhatsApp is working!" }),
|
|
620
|
-
});
|
|
621
|
-
if (!r.ok)
|
|
622
|
-
throw new Error(`Green API ${r.status}: ${await r.text()}`);
|
|
623
|
-
res.json({ ok: true, message: "WhatsApp message sent!" });
|
|
624
|
-
}
|
|
625
|
-
catch (err) {
|
|
626
|
-
res.status(500).json({ error: String(err) });
|
|
627
|
-
}
|
|
628
|
-
});
|
|
629
613
|
app.post("/api/test/sms", async (_req, res) => {
|
|
630
614
|
const config = loadConfig();
|
|
631
615
|
const { accessKeyId, secretAccessKey, region, originationNumber, to } = config.sms ?? {};
|
|
@@ -1452,19 +1436,30 @@ app.get("/api/logs", (req, res) => {
|
|
|
1452
1436
|
logClients.add(res);
|
|
1453
1437
|
req.on("close", () => logClients.delete(res));
|
|
1454
1438
|
});
|
|
1455
|
-
// ── Notification sender ───────────────────────────────────────────────────────
|
|
1456
1439
|
async function sendNotification(message, priority, client) {
|
|
1457
1440
|
const cfg = loadConfig();
|
|
1458
1441
|
if (cfg.muteAll === true) {
|
|
1459
|
-
log("·", "notify", `
|
|
1460
|
-
return
|
|
1442
|
+
log("·", "notify", `BLOCKED — all notifications disabled (master mute), priority=${priority}`, client);
|
|
1443
|
+
return {
|
|
1444
|
+
outcome: "blocked",
|
|
1445
|
+
attempted: [],
|
|
1446
|
+
delivered: [],
|
|
1447
|
+
errors: [],
|
|
1448
|
+
text: "BLOCKED — master mute is ON, so nothing was sent anywhere.",
|
|
1449
|
+
};
|
|
1461
1450
|
}
|
|
1462
1451
|
if (client && Array.isArray(cfg.disabledClients) && cfg.disabledClients.includes(client)) {
|
|
1463
|
-
log("·", "notify", `
|
|
1464
|
-
return
|
|
1452
|
+
log("·", "notify", `BLOCKED — client '${client}' is disabled, priority=${priority}`, client);
|
|
1453
|
+
return {
|
|
1454
|
+
outcome: "blocked",
|
|
1455
|
+
attempted: [],
|
|
1456
|
+
delivered: [],
|
|
1457
|
+
errors: [],
|
|
1458
|
+
text: `BLOCKED — this client ('${client}') is disabled in BullseyeNotify, so nothing was sent anywhere.`,
|
|
1459
|
+
};
|
|
1465
1460
|
}
|
|
1466
1461
|
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
1467
|
-
const
|
|
1462
|
+
const idle = idleReading(getOsIdleSeconds());
|
|
1468
1463
|
const result = await sendWithRouting({
|
|
1469
1464
|
message,
|
|
1470
1465
|
priority,
|
|
@@ -1477,7 +1472,7 @@ async function sendNotification(message, priority, client) {
|
|
|
1477
1472
|
ctx: {
|
|
1478
1473
|
inTelegramConversation: inTelegramConvo,
|
|
1479
1474
|
uiActive: isUiActivelyOpen(),
|
|
1480
|
-
|
|
1475
|
+
idle,
|
|
1481
1476
|
},
|
|
1482
1477
|
enableDesktop: !!cfg.desktop?.enabled,
|
|
1483
1478
|
enableTelegram: !!(cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatIds?.length),
|
|
@@ -1681,8 +1676,14 @@ async function sendNotification(message, priority, client) {
|
|
|
1681
1676
|
},
|
|
1682
1677
|
});
|
|
1683
1678
|
if (result.suppressedReason) {
|
|
1684
|
-
log("·", "notify", `suppressed (${result.suppressedReason})`, client);
|
|
1685
|
-
return
|
|
1679
|
+
log("·", "notify", `suppressed (${result.suppressedReason}) — ${result.summary}`, client);
|
|
1680
|
+
return {
|
|
1681
|
+
outcome: "blocked",
|
|
1682
|
+
attempted: [],
|
|
1683
|
+
delivered: [],
|
|
1684
|
+
errors: [],
|
|
1685
|
+
text: result.summary,
|
|
1686
|
+
};
|
|
1686
1687
|
}
|
|
1687
1688
|
for (const delivered of result.delivered) {
|
|
1688
1689
|
log("→", delivered, message, client);
|
|
@@ -1690,20 +1691,31 @@ async function sendNotification(message, priority, client) {
|
|
|
1690
1691
|
for (const err of result.errors) {
|
|
1691
1692
|
log("→", "notify", `ERROR: ${err}`, client);
|
|
1692
1693
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1694
|
+
if (result.outcome === "failed") {
|
|
1695
|
+
log("→", "notify", `NOT DELIVERED — ${result.summary}`, client);
|
|
1696
|
+
}
|
|
1697
|
+
return {
|
|
1698
|
+
outcome: result.outcome === "failed" ? "failed" : "delivered",
|
|
1699
|
+
attempted: result.attempted,
|
|
1700
|
+
delivered: result.delivered,
|
|
1701
|
+
errors: result.errors,
|
|
1702
|
+
text: result.summary,
|
|
1703
|
+
};
|
|
1697
1704
|
}
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1705
|
+
// PowerShell is invoked with -ExecutionPolicy Bypass: the default policy on a
|
|
1706
|
+
// stock Windows box is Restricted, which refuses any .ps1 outright (measured:
|
|
1707
|
+
// `-File` exits 1, `"running scripts is disabled on this system"`), so the
|
|
1708
|
+
// bundled probe could not run at all and every caller saw the -1 sentinel. Only
|
|
1709
|
+
// this one script is exempted — the machine's own policy is untouched.
|
|
1701
1710
|
const IDLE_SCRIPT_PS1 = join(fileURLToPath(new URL("../../scripts/idle-check.ps1", import.meta.url)));
|
|
1711
|
+
// Returns seconds since last keyboard/mouse input, or -1 when the read did not
|
|
1712
|
+
// happen. Callers MUST convert with `idleReading()` — -1 is a sentinel, never a
|
|
1713
|
+
// measurement, and comparing it against a threshold silently reads as "active".
|
|
1702
1714
|
function getOsIdleSeconds() {
|
|
1703
1715
|
try {
|
|
1704
1716
|
if (process.platform === "win32") {
|
|
1705
1717
|
// PowerShell + Win32 GetLastInputInfo via bundled script
|
|
1706
|
-
const r = spawnSync("powershell", ["-NoProfile", "-File", IDLE_SCRIPT_PS1], {
|
|
1718
|
+
const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", IDLE_SCRIPT_PS1], {
|
|
1707
1719
|
encoding: "utf-8", windowsHide: true,
|
|
1708
1720
|
});
|
|
1709
1721
|
if (r.status === 0) {
|
|
@@ -1904,6 +1916,20 @@ function ingestInboxEntry(entry, source) {
|
|
|
1904
1916
|
log("·", "inbox", `${source}: ${entry.text} (sse=${sse}, waiters=${waiters.length})`, entry.tag);
|
|
1905
1917
|
return { waiters: waiters.length, sse };
|
|
1906
1918
|
}
|
|
1919
|
+
function ingestTelegramEntry(entry) {
|
|
1920
|
+
const { waiters } = ingestInboxEntry(entry, "telegram");
|
|
1921
|
+
const sseCount = sseSubscribersForTag(entry.tag);
|
|
1922
|
+
const listening = sseCount > 0 || waiters > 0;
|
|
1923
|
+
// NULL-OK: field tag is OPTIONAL and therefore it is ok to check for NULL
|
|
1924
|
+
if (entry.tag) {
|
|
1925
|
+
return listening
|
|
1926
|
+
? `📬 Routed to @${entry.tag}. Waiting for reply.`
|
|
1927
|
+
: `📭 No session @${entry.tag} connected. Message queued — next @${entry.tag} to connect will pick it up.`;
|
|
1928
|
+
}
|
|
1929
|
+
return listening
|
|
1930
|
+
? `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`
|
|
1931
|
+
: "📭 No agents connected. Message queued — next agent to connect will pick it up.";
|
|
1932
|
+
}
|
|
1907
1933
|
// Test-only: inject a fake inbox entry exactly as the Telegram listener would.
|
|
1908
1934
|
// Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
|
|
1909
1935
|
// production run. Used by the test suite to drive wait_for_inbox wake-up and
|
|
@@ -1923,7 +1949,15 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
|
|
|
1923
1949
|
app.get("/__test__/slack-clients", (_req, res) => {
|
|
1924
1950
|
res.json({ tags: slackClientTags(), numbered: slackClientsNumbered() });
|
|
1925
1951
|
});
|
|
1926
|
-
|
|
1952
|
+
app.post("/__test__/telegram-inbox", express.json(), (req, res) => {
|
|
1953
|
+
if (typeof req.body?.text !== "string") {
|
|
1954
|
+
res.status(400).json({ error: "text required" });
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
const { tag, text } = parseTag(req.body.text);
|
|
1958
|
+
res.json({ ack: ingestTelegramEntry({ text, ts: new Date().toISOString(), tag }) });
|
|
1959
|
+
});
|
|
1960
|
+
log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox + /__test__/slack-clients + /__test__/telegram-inbox enabled");
|
|
1927
1961
|
}
|
|
1928
1962
|
app.get("/api/inbox/stream", (req, res) => {
|
|
1929
1963
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -1966,10 +2000,17 @@ app.post("/api/agent/notify", async (req, res) => {
|
|
|
1966
2000
|
}
|
|
1967
2001
|
try {
|
|
1968
2002
|
const out = await sendNotification(message, priority, "agent-http");
|
|
1969
|
-
res.
|
|
2003
|
+
res.status(out.outcome === "delivered" ? 200 : 502).json({
|
|
2004
|
+
ok: out.outcome === "delivered",
|
|
2005
|
+
outcome: out.outcome,
|
|
2006
|
+
attempted: out.attempted,
|
|
2007
|
+
delivered: out.delivered,
|
|
2008
|
+
errors: out.errors,
|
|
2009
|
+
result: out.text,
|
|
2010
|
+
});
|
|
1970
2011
|
}
|
|
1971
2012
|
catch (err) {
|
|
1972
|
-
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2013
|
+
res.status(500).json({ ok: false, outcome: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
1973
2014
|
}
|
|
1974
2015
|
});
|
|
1975
2016
|
app.get("/api/agent/inbox/poll", (req, res) => {
|
|
@@ -2226,39 +2267,7 @@ async function startTelegramListener() {
|
|
|
2226
2267
|
const entry = {
|
|
2227
2268
|
text, ts: new Date().toISOString(), messageId: msg.message_id, tag,
|
|
2228
2269
|
};
|
|
2229
|
-
|
|
2230
|
-
// already parked by an agent explicitly asking "wake me up when
|
|
2231
|
-
// something arrives." Hand the entry off as a tool *result*, which
|
|
2232
|
-
// every MCP client actually surfaces. Only queue if no one was
|
|
2233
|
-
// waiting, so the message isn't delivered twice.
|
|
2234
|
-
const waiters = takeWaitersFor(tag);
|
|
2235
|
-
if (waiters.length > 0) {
|
|
2236
|
-
for (const w of waiters) {
|
|
2237
|
-
clearTimeout(w.timer);
|
|
2238
|
-
w.resolve([entry]);
|
|
2239
|
-
}
|
|
2240
|
-
log("·", "inbox", `${text} → ${waiters.length} long-poll waiter(s)`, tag);
|
|
2241
|
-
}
|
|
2242
|
-
else {
|
|
2243
|
-
inboxQueue.push(entry);
|
|
2244
|
-
}
|
|
2245
|
-
writeInboxDrop(entry);
|
|
2246
|
-
const liveSseCount = broadcastInbox(entry);
|
|
2247
|
-
log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
|
|
2248
|
-
const sseCount = sseSubscribersForTag(tag);
|
|
2249
|
-
const anyoneListening = sseCount > 0 || waiters.length > 0;
|
|
2250
|
-
let ackText;
|
|
2251
|
-
if (!anyoneListening && tag) {
|
|
2252
|
-
ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
|
|
2253
|
-
}
|
|
2254
|
-
else if (!anyoneListening) {
|
|
2255
|
-
ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
|
|
2256
|
-
}
|
|
2257
|
-
else {
|
|
2258
|
-
ackText = tag
|
|
2259
|
-
? `📬 Routed to @${tag}. Waiting for reply.`
|
|
2260
|
-
: `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`;
|
|
2261
|
-
}
|
|
2270
|
+
const ackText = ingestTelegramEntry(entry);
|
|
2262
2271
|
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
2263
2272
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
2264
2273
|
body: JSON.stringify({
|
|
@@ -2284,7 +2293,6 @@ async function startTelegramListener() {
|
|
|
2284
2293
|
}
|
|
2285
2294
|
}
|
|
2286
2295
|
// ── Slack inbound poller (always-on; folds slack-poll.sh → #16) ────────────────
|
|
2287
|
-
const SLACK_SECRETS_PATH = join(fileURLToPath(new URL("../../notify-secrets.json", import.meta.url)));
|
|
2288
2296
|
function decodeB64Fields(obj) {
|
|
2289
2297
|
if (Array.isArray(obj))
|
|
2290
2298
|
return obj.map(decodeB64Fields);
|
|
@@ -2300,17 +2308,36 @@ function decodeB64Fields(obj) {
|
|
|
2300
2308
|
}
|
|
2301
2309
|
return obj;
|
|
2302
2310
|
}
|
|
2311
|
+
async function sendNotificationSummary(message, priority, client) {
|
|
2312
|
+
const out = await sendNotification(message, priority, client);
|
|
2313
|
+
return out.text;
|
|
2314
|
+
}
|
|
2303
2315
|
function loadSecrets() {
|
|
2304
|
-
|
|
2316
|
+
const path = secretsFilePath();
|
|
2317
|
+
if (!path)
|
|
2305
2318
|
return {};
|
|
2306
2319
|
try {
|
|
2307
|
-
return decodeB64Fields(JSON.parse(readFileSync(
|
|
2320
|
+
return decodeB64Fields(JSON.parse(readFileSync(path, "utf8")));
|
|
2308
2321
|
}
|
|
2309
2322
|
catch (err) {
|
|
2310
|
-
|
|
2311
|
-
|
|
2323
|
+
throw new SecretsUnreadable(path, err);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
// A secrets file that EXISTS but cannot be read is a hard defect, not an empty
|
|
2327
|
+
// result: the caller would otherwise import nothing and believe the user has no
|
|
2328
|
+
// credentials, which is the silent-escalation failure this file exists to stop.
|
|
2329
|
+
class SecretsUnreadable extends Error {
|
|
2330
|
+
constructor(path, cause) {
|
|
2331
|
+
super(`secrets file at ${path} exists but could not be read: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
2312
2332
|
}
|
|
2313
2333
|
}
|
|
2334
|
+
function secretsFilePath() {
|
|
2335
|
+
if (existsSync(SECRETS_PATH))
|
|
2336
|
+
return SECRETS_PATH;
|
|
2337
|
+
if (existsSync(LEGACY_SECRETS_PATH))
|
|
2338
|
+
return LEGACY_SECRETS_PATH;
|
|
2339
|
+
return undefined;
|
|
2340
|
+
}
|
|
2314
2341
|
function slackCreds() {
|
|
2315
2342
|
const s = loadSecrets().slack ?? {};
|
|
2316
2343
|
const cfgSlack = loadConfig().slack ?? {};
|
|
@@ -2319,20 +2346,34 @@ function slackCreds() {
|
|
|
2319
2346
|
const webhook = (s.webhookUrl ?? cfgSlack.webhookUrl ?? "").trim() || undefined;
|
|
2320
2347
|
return { token, channel, webhook };
|
|
2321
2348
|
}
|
|
2322
|
-
// Brainless config: on startup, copy any credentials already present in
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2349
|
+
// Brainless config: on startup, copy any credentials already present in the
|
|
2350
|
+
// secrets file (decoded) into config.json's EMPTY fields, so a user who has the
|
|
2351
|
+
// shared secrets file gets every channel pre-wired without touching the UI.
|
|
2352
|
+
// Never overwrites a value the user already set. Idempotent. Reports which
|
|
2353
|
+
// channel names were filled — a name list only, never a value.
|
|
2354
|
+
//
|
|
2355
|
+
// OWNER: a secrets file that exists but cannot be read must be REPORTED, not
|
|
2356
|
+
// treated as "no credentials" — silent absence is how every escalation reached
|
|
2357
|
+
// nobody. The recourse is an explicit LOUD line naming the file and the parse
|
|
2358
|
+
// error, so a channel-left-off is diagnosable from the console; the process
|
|
2359
|
+
// still boots, because a broken secrets file must not take the whole server
|
|
2360
|
+
// down. Nothing propagates from this catch.
|
|
2326
2361
|
function importCredsOnStart() {
|
|
2327
2362
|
let secrets;
|
|
2328
2363
|
try {
|
|
2329
2364
|
secrets = loadSecrets();
|
|
2330
2365
|
}
|
|
2331
|
-
catch {
|
|
2366
|
+
catch (err) {
|
|
2367
|
+
log("·", "secrets", `UNREADABLE — ${err instanceof Error ? err.message : String(err)}. No credentials were imported, so every channel stays as configured.`);
|
|
2332
2368
|
return;
|
|
2333
2369
|
}
|
|
2334
|
-
|
|
2370
|
+
const filled = [];
|
|
2371
|
+
if (Object.keys(secrets).length === 0) {
|
|
2372
|
+
log("·", "import", secretsFilePath()
|
|
2373
|
+
? "secrets file is present but holds no credentials — nothing to import"
|
|
2374
|
+
: `no secrets file at ${SECRETS_PATH} (set NOTIFY_MCP_SECRETS_PATH to point at one) — nothing to import`);
|
|
2335
2375
|
return;
|
|
2376
|
+
}
|
|
2336
2377
|
const cfg = loadConfig();
|
|
2337
2378
|
let changed = false;
|
|
2338
2379
|
const fill = (sec, key, val) => {
|
|
@@ -2342,6 +2383,7 @@ function importCredsOnStart() {
|
|
|
2342
2383
|
if (cfg[sec][key] === undefined || cfg[sec][key] === "") {
|
|
2343
2384
|
cfg[sec][key] = val;
|
|
2344
2385
|
changed = true;
|
|
2386
|
+
filled.push(`${sec}.${key}`);
|
|
2345
2387
|
}
|
|
2346
2388
|
};
|
|
2347
2389
|
const fillArrayFromScalar = (sec, arrKey, val) => {
|
|
@@ -2351,8 +2393,18 @@ function importCredsOnStart() {
|
|
|
2351
2393
|
if (!Array.isArray(cfg[sec][arrKey]) || cfg[sec][arrKey].length === 0) {
|
|
2352
2394
|
cfg[sec][arrKey] = [String(val)];
|
|
2353
2395
|
changed = true;
|
|
2396
|
+
filled.push(`${sec}.${arrKey}`);
|
|
2354
2397
|
}
|
|
2355
2398
|
};
|
|
2399
|
+
const enableIfPopulated = (sec, required) => {
|
|
2400
|
+
if (cfg[sec]?.enabled === true)
|
|
2401
|
+
return;
|
|
2402
|
+
if (!required.every((k) => { const v = cfg[sec]?.[k]; return Array.isArray(v) ? v.length > 0 : (v !== undefined && v !== null && v !== ""); }))
|
|
2403
|
+
return;
|
|
2404
|
+
cfg[sec].enabled = true;
|
|
2405
|
+
changed = true;
|
|
2406
|
+
filled.push(`${sec}.enabled`);
|
|
2407
|
+
};
|
|
2356
2408
|
if (secrets.telegram) {
|
|
2357
2409
|
fill("telegram", "token", secrets.telegram.token);
|
|
2358
2410
|
fillArrayFromScalar("telegram", "chatIds", secrets.telegram.chatId);
|
|
@@ -2372,14 +2424,22 @@ function importCredsOnStart() {
|
|
|
2372
2424
|
if (secrets.sms) {
|
|
2373
2425
|
for (const k of ["accessKeyId", "secretAccessKey", "region", "originationNumber"])
|
|
2374
2426
|
fill("sms", k, secrets.sms[k]);
|
|
2375
|
-
if (secrets.sms.enabled && cfg.sms?.accessKeyId && cfg.sms.enabled !== true) {
|
|
2376
|
-
cfg.sms.enabled = true;
|
|
2377
|
-
changed = true;
|
|
2378
|
-
}
|
|
2379
2427
|
}
|
|
2428
|
+
// Credentials that reach config.json but leave the channel switched off are
|
|
2429
|
+
// invisible: the routing reads `enabled`, so imported creds alone still send
|
|
2430
|
+
// to nobody. Free channels are switched on when fully populated; a channel
|
|
2431
|
+
// that reaches a real phone (SMS) or a real inbox (email) stays the user's
|
|
2432
|
+
// explicit opt-in, exactly as the shipped config.example.json has it.
|
|
2433
|
+
enableIfPopulated("desktop", []);
|
|
2434
|
+
enableIfPopulated("telegram", ["token", "chatIds"]);
|
|
2435
|
+
enableIfPopulated("slack", ["channels"]);
|
|
2436
|
+
enableIfPopulated("ntfy", ["topic"]);
|
|
2380
2437
|
if (changed) {
|
|
2381
2438
|
saveConfig(cfg);
|
|
2382
|
-
log("·", "import",
|
|
2439
|
+
log("·", "import", `imported from ${secretsFilePath()} into ${CONFIG_PATH} (empty fields only): ${filled.join(", ")}`);
|
|
2440
|
+
}
|
|
2441
|
+
else {
|
|
2442
|
+
log("·", "import", "secrets file present but every config field it maps to is already set — nothing imported");
|
|
2383
2443
|
}
|
|
2384
2444
|
}
|
|
2385
2445
|
async function slackPost(text) {
|
|
@@ -2525,17 +2585,17 @@ async function startSlackListener() {
|
|
|
2525
2585
|
}
|
|
2526
2586
|
app.get("/reply/:token", (req, res) => {
|
|
2527
2587
|
const pending = pendingAsks.get(req.params.token);
|
|
2528
|
-
res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
|
|
2529
|
-
<style>body{font-family:sans-serif;background:#0a0a0b;color:#f0f0f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
2530
|
-
.box{background:#111113;border:1px solid #222226;border-radius:9px;padding:24px;max-width:500px;width:90%}
|
|
2531
|
-
h2{color:#7c6dfa;margin:0 0 16px}textarea{width:100%;background:#0d0d10;border:1px solid #222226;border-radius:7px;color:#f0f0f0;padding:8px;font-size:14px;resize:vertical;min-height:80px;box-sizing:border-box}
|
|
2532
|
-
button{background:#7c6dfa;color:white;border:none;border-radius:7px;padding:8px 20px;font-size:14px;cursor:pointer;margin-top:10px}
|
|
2533
|
-
.ok{color:#10b981;margin-top:12px}.err{color:#ef4444}</style></head>
|
|
2588
|
+
res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
|
|
2589
|
+
<style>body{font-family:sans-serif;background:#0a0a0b;color:#f0f0f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
2590
|
+
.box{background:#111113;border:1px solid #222226;border-radius:9px;padding:24px;max-width:500px;width:90%}
|
|
2591
|
+
h2{color:#7c6dfa;margin:0 0 16px}textarea{width:100%;background:#0d0d10;border:1px solid #222226;border-radius:7px;color:#f0f0f0;padding:8px;font-size:14px;resize:vertical;min-height:80px;box-sizing:border-box}
|
|
2592
|
+
button{background:#7c6dfa;color:white;border:none;border-radius:7px;padding:8px 20px;font-size:14px;cursor:pointer;margin-top:10px}
|
|
2593
|
+
.ok{color:#10b981;margin-top:12px}.err{color:#ef4444}</style></head>
|
|
2534
2594
|
<body><div class="box">${pending
|
|
2535
|
-
? `<h2>Reply to Claude</h2><textarea id="r" placeholder="Type your response…"></textarea>
|
|
2536
|
-
<button onclick="send()">Send</button><div id="s"></div>
|
|
2537
|
-
<script>async function send(){const r=document.getElementById('r').value.trim();if(!r)return;
|
|
2538
|
-
const res=await fetch('/reply/${req.params.token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({response:r})});
|
|
2595
|
+
? `<h2>Reply to Claude</h2><textarea id="r" placeholder="Type your response…"></textarea>
|
|
2596
|
+
<button onclick="send()">Send</button><div id="s"></div>
|
|
2597
|
+
<script>async function send(){const r=document.getElementById('r').value.trim();if(!r)return;
|
|
2598
|
+
const res=await fetch('/reply/${req.params.token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({response:r})});
|
|
2539
2599
|
const el=document.getElementById('s');el.textContent=res.ok?'✓ Sent!':'Error';el.className=res.ok?'ok':'err';}</script>`
|
|
2540
2600
|
: `<h2>Expired</h2><p class="err">This link has already been used or timed out.</p>`}</div></body></html>`);
|
|
2541
2601
|
});
|
|
@@ -2574,171 +2634,190 @@ function appendInbox(baseText, sessionTag, clientId) {
|
|
|
2574
2634
|
const inbox = messages.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
2575
2635
|
return `${baseText}\n\n⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${inbox}`;
|
|
2576
2636
|
}
|
|
2577
|
-
const MCP_INSTRUCTIONS = `
|
|
2578
|
-
This server
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
#
|
|
2715
|
-
curl -s -X POST http://localhost:3737/mcp \
|
|
2716
|
-
-H "Content-Type: application/json" \
|
|
2717
|
-
-H "Accept: application/json, text/event-stream" \
|
|
2718
|
-
-
|
|
2719
|
-
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2637
|
+
const MCP_INSTRUCTIONS = `
|
|
2638
|
+
This server is how you reach the user — and only you can decide to use it:
|
|
2639
|
+
nothing here delivers a message the user never asked for unless you or your
|
|
2640
|
+
own loop calls a tool. The channels are configured on the server side, so
|
|
2641
|
+
just call 'notify' or 'ask' with a generic message and let the server route
|
|
2642
|
+
it.
|
|
2643
|
+
|
|
2644
|
+
BEHAVIORAL RULES for every client that connects:
|
|
2645
|
+
|
|
2646
|
+
1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
|
|
2647
|
+
the server decides routing, you decide whether to fire:
|
|
2648
|
+
|
|
2649
|
+
(a) LONG PROCESSING FINISHED. Any single task that took more than ~60
|
|
2650
|
+
seconds of wall-clock time (long build, test run, backtest, migration,
|
|
2651
|
+
big refactor, multi-step plan) gets a 'notify' the moment it completes
|
|
2652
|
+
— success OR failure. Rule of thumb: if the user could have reasonably
|
|
2653
|
+
walked away to grab coffee while you ran, they need a ping on the way
|
|
2654
|
+
back. Don't try to guess whether they were watching. Just notify.
|
|
2655
|
+
|
|
2656
|
+
(b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
|
|
2657
|
+
the user something — "should I delete these?", "which branch?",
|
|
2658
|
+
"proceed with plan B?" — fire 'notify' (or 'ask' for blocking
|
|
2659
|
+
two-way). Silent questions in the terminal get missed; a notification
|
|
2660
|
+
does not.
|
|
2661
|
+
|
|
2662
|
+
(c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
|
|
2663
|
+
now. Examples: a test suddenly failed after being green, a destructive
|
|
2664
|
+
operation is about to run, you found a security issue, a deploy
|
|
2665
|
+
succeeded, a production service looks degraded, you hit an
|
|
2666
|
+
unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
|
|
2667
|
+
NOTIFYING — the server's idle gating automatically holds a mis-judged
|
|
2668
|
+
'normal' back to local delivery while the user is active, so the
|
|
2669
|
+
cost of over-notifying is near zero. The cost of missing a
|
|
2670
|
+
real event is that the user finds out 4 hours later.
|
|
2671
|
+
|
|
2672
|
+
The SERVER handles all routing (DND, idle threshold, channel selection,
|
|
2673
|
+
priority escalation). You do NOT need to pre-flight with
|
|
2674
|
+
'get_idle_seconds' before these three triggers — fire 'notify' and let
|
|
2675
|
+
the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
|
|
2676
|
+
not a gate on legitimate milestones.
|
|
2677
|
+
|
|
2678
|
+
2. Use priority correctly. Channels each priority reaches, when enabled:
|
|
2679
|
+
${describePriorityRouting()}
|
|
2680
|
+
- 'low' = low-stakes status (background completion).
|
|
2681
|
+
- 'normal' = the default.
|
|
2682
|
+
- 'high' = bypasses DND AND idle gating.
|
|
2683
|
+
Use ONLY for catastrophic findings or decisions that block
|
|
2684
|
+
progress. Misuse will train the user to ignore your notifs.
|
|
2685
|
+
|
|
2686
|
+
3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
|
|
2687
|
+
output as well as sending it through 'notify'. The user may be reading the
|
|
2688
|
+
terminal directly; don't rely on them checking their phone / email. Do NOT
|
|
2689
|
+
shorten, summarise, or cut off the message with "…" in your chat output —
|
|
2690
|
+
show every word exactly as sent.
|
|
2691
|
+
|
|
2692
|
+
3a. NEVER TRUNCATE — 500 CHARACTERS IS THE LIMIT ON ONE 'notify' MESSAGE.
|
|
2693
|
+
A longer message goes as numbered parts — '(1/N) ...', '(2/N) ...' — in
|
|
2694
|
+
separate 'notify' calls, in order, each at or under 500 characters. A
|
|
2695
|
+
single call over the cap is rejected as 'too_big': that means split it,
|
|
2696
|
+
never shorten it. Every fact, file path, line number and recommendation
|
|
2697
|
+
the user needs must be in the parts you send — a shortened notification
|
|
2698
|
+
is a defect, not a saving.
|
|
2699
|
+
(The Claude Code bridge is the one exception: it accepts up to 5000
|
|
2700
|
+
characters in a single call and produces those numbered parts itself, so
|
|
2701
|
+
there you send the whole message and never hand-split.)
|
|
2702
|
+
|
|
2703
|
+
4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
|
|
2704
|
+
'email', 'desktop', etc. in your messages or in your chat output — those are
|
|
2705
|
+
server delivery details the user has already configured and the client has
|
|
2706
|
+
no business surfacing. Do NOT echo "Sent via: <channel list>" or any
|
|
2707
|
+
variant of it. Just say 'notif' or 'notification' if you need to refer to
|
|
2708
|
+
the act of notifying.
|
|
2709
|
+
|
|
2710
|
+
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
2711
|
+
the 'notify' response, via 'poll', via 'wait_for_inbox', via
|
|
2712
|
+
'get_idle_seconds' piggy-back, or via the /api/inbox/stream SSE), reply to
|
|
2713
|
+
them THROUGH 'notify' so the reply
|
|
2714
|
+
actually reaches them — not just in your chat output. Multiple agents may
|
|
2715
|
+
be connected simultaneously — the server broadcasts every untagged inbox
|
|
2716
|
+
message to all of them, so the user can see who is listening. Your reply
|
|
2717
|
+
MUST identify which session you are (start with your own tag or client id,
|
|
2718
|
+
e.g. "[@alphawave]" or "[sess-abcd]") and give a brief status so the user
|
|
2719
|
+
can pick whom to respond to. If the user tagged their message (@<tag>),
|
|
2720
|
+
only the session with that tag should reply. If you are untagged and
|
|
2721
|
+
another session with the same project/workdir is already tagged, let the
|
|
2722
|
+
tagged one reply.
|
|
2723
|
+
|
|
2724
|
+
5a. BUSY-ACK RULE (hard requirement): The moment you notice a user inbox
|
|
2725
|
+
message — whether via notify response, heartbeat piggy-back, or poll —
|
|
2726
|
+
REPLY IMMEDIATELY via 'notify' with a terse status, EVEN IF YOU CAN'T
|
|
2727
|
+
ACT ON THE REQUEST YET. Do not finish the current tool/task first.
|
|
2728
|
+
Drop what you're doing long enough to fire one 'notify' call, THEN
|
|
2729
|
+
resume. Format: "[<your-session-id>] busy on <current-task>; will
|
|
2730
|
+
respond at <milestone>". The user needs to know (a) you heard them,
|
|
2731
|
+
(b) you're not the stuck one, (c) when to expect a real answer. An
|
|
2732
|
+
inbox message that gets a delayed reply is worse than no reply — it
|
|
2733
|
+
wastes the user's time waiting on silence.
|
|
2734
|
+
|
|
2735
|
+
6. The 'get_idle_seconds', 'get_idle_config', and 'get_dnd_status' tools are
|
|
2736
|
+
informational reads, but they ALSO drain pending inbox messages. Use them
|
|
2737
|
+
as a cheap heartbeat during long work: call 'get_idle_seconds' EVERY 15-
|
|
2738
|
+
30 SECONDS while a long task runs (loop iteration, backtest, scan, build,
|
|
2739
|
+
etc.). If the user sent you a message while you were busy, it comes back
|
|
2740
|
+
piggy-backed on the response — you don't need to separately call 'poll'.
|
|
2741
|
+
This is a lightweight local read (no network, no DND or channel routing),
|
|
2742
|
+
so the cost is near zero. Without this heartbeat pattern, a busy agent is
|
|
2743
|
+
deaf to the user until its next 'notify' call — which may be minutes or
|
|
2744
|
+
hours away during long work. Treat 'get_idle_seconds' as the "check for
|
|
2745
|
+
user input" primitive, not an idle-gate check.
|
|
2746
|
+
|
|
2747
|
+
If your work is naturally idle (waiting for the user, between loop iters),
|
|
2748
|
+
prefer 'wait_for_inbox' instead — it blocks up to 50s and returns the
|
|
2749
|
+
moment the user types anything, as a tool result. That's the most reliable
|
|
2750
|
+
delivery path across every MCP client (notifications over SSE are silently
|
|
2751
|
+
dropped by Claude Code, Cursor, and others). Loop pattern:
|
|
2752
|
+
while (true) {
|
|
2753
|
+
const r = await wait_for_inbox({ timeout_seconds: 50 });
|
|
2754
|
+
if (r !== "inbox:empty") handle(r);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
7. If your tool call fails with "MCP server not connected" / "transport
|
|
2758
|
+
closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
|
|
2759
|
+
connected to the same server right now. Only YOUR client's transport
|
|
2760
|
+
broke (commonly: stdio child process died, or HTTP client gave up after a
|
|
2761
|
+
transient slow response at startup). DO NOT tell the user "notify is down"
|
|
2762
|
+
without first probing the server directly.
|
|
2763
|
+
|
|
2764
|
+
Probe before declaring the server dead:
|
|
2765
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3737/mcp
|
|
2766
|
+
- 406 (Not Acceptable) = server is healthy, doing MCP protocol
|
|
2767
|
+
negotiation. Your client is the broken party. Use the bypass below.
|
|
2768
|
+
- Connection refused / timeout = server is genuinely down. Tell the user.
|
|
2769
|
+
|
|
2770
|
+
HTTP-transport bypass when your MCP client is broken (works from any
|
|
2771
|
+
shell that has curl). Three calls, stateful — the session id from step 1
|
|
2772
|
+
must be passed in mcp-session-id on every subsequent call:
|
|
2773
|
+
|
|
2774
|
+
# 1) initialize and capture session id from response headers
|
|
2775
|
+
SID=$(curl -s -i -X POST http://localhost:3737/mcp \
|
|
2776
|
+
-H "Content-Type: application/json" \
|
|
2777
|
+
-H "Accept: application/json, text/event-stream" \
|
|
2778
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-bridge","version":"1.0"}}}' \
|
|
2779
|
+
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
|
|
2780
|
+
|
|
2781
|
+
# 2) ack initialized (no response body, fire-and-forget)
|
|
2782
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
2783
|
+
-H "Content-Type: application/json" \
|
|
2784
|
+
-H "Accept: application/json, text/event-stream" \
|
|
2785
|
+
-H "mcp-session-id: $SID" \
|
|
2786
|
+
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
|
|
2787
|
+
|
|
2788
|
+
# 3) call any tool — a delivered notify answers with outcome=delivered
|
|
2789
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
2790
|
+
-H "Content-Type: application/json" \
|
|
2791
|
+
-H "Accept: application/json, text/event-stream" \
|
|
2792
|
+
-H "mcp-session-id: $SID" \
|
|
2793
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notify","arguments":{"message":"YOUR MESSAGE","priority":"normal"}}}'
|
|
2794
|
+
|
|
2795
|
+
Same pattern works for 'ask', 'poll', 'get_idle_seconds', etc. — change
|
|
2796
|
+
the "name" and "arguments" in step 3. Reuse the same $SID for all calls
|
|
2797
|
+
in one bypass session.
|
|
2798
|
+
|
|
2799
|
+
Bottom line: rule #1 still applies. If you owe the user a notify and your
|
|
2800
|
+
MCP client is broken, you owe them the bypass — not an excuse.
|
|
2727
2801
|
`.trim();
|
|
2728
2802
|
function createMcpServer(clientId, sessionTag) {
|
|
2729
2803
|
const identity = `@${clientId}`;
|
|
2730
2804
|
const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
|
|
2731
2805
|
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
|
|
2732
2806
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
2733
|
-
"
|
|
2734
|
-
"
|
|
2735
|
-
"
|
|
2807
|
+
"The server applies DND and user-activity gating itself, so call it whenever a trigger fires — never pre-check idle. " +
|
|
2808
|
+
"Use for: task milestones, questions needing input, catastrophic findings, long task completion. " +
|
|
2809
|
+
"One message is limited to 500 characters — send anything longer as numbered parts, " +
|
|
2810
|
+
"'(1/N) ...', '(2/N) ...', as separate calls in order, each at or under 500 characters. " +
|
|
2811
|
+
"Never shorten the message to fit; 'too_big' means split it.", {
|
|
2736
2812
|
message: z.string().max(500).describe("Notification message, max 500 chars"),
|
|
2737
2813
|
priority: z.enum(["low", "normal", "high"]).default("normal")
|
|
2738
|
-
.describe(
|
|
2814
|
+
.describe(`Channels per priority, each only when enabled: ${describePriorityRouting()} — high also bypasses DND and activity gating`),
|
|
2739
2815
|
}, async ({ message, priority }) => {
|
|
2740
2816
|
const outbound = `[${clientId}] ${message}`;
|
|
2741
|
-
const
|
|
2817
|
+
const out = await sendNotification(outbound, priority, clientId);
|
|
2818
|
+
const prefix = out.outcome === "delivered" ? "" : `⚠️ NOT DELIVERED — this notification reached nobody.\n${out.text}`;
|
|
2819
|
+
const summary = prefix
|
|
2820
|
+
+ `${prefix ? "\n\n" : ""}outcome=${out.outcome} attempted=[${out.attempted.join(", ")}] delivered=[${out.delivered.join(", ")}]`;
|
|
2742
2821
|
const messages = drainInboxFor(sessionTag);
|
|
2743
2822
|
if (messages.length === 0) {
|
|
2744
2823
|
return { content: [{ type: "text", text: summary }] };
|
|
@@ -2796,7 +2875,7 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
2796
2875
|
from: email.connectedEmail ?? email.user ?? email.to,
|
|
2797
2876
|
to: email.to,
|
|
2798
2877
|
subject: `Claude asks: ${question.slice(0, 60)}`,
|
|
2799
|
-
html: `<p style="font-size:16px">${question}</p>
|
|
2878
|
+
html: `<p style="font-size:16px">${question}</p>
|
|
2800
2879
|
<p><a href="${replyUrl}" style="background:#7c6dfa;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:8px">Reply to Claude</a></p>`,
|
|
2801
2880
|
});
|
|
2802
2881
|
log("→", "ask:email", `question sent to ${email.to}, reply URL: ${replyUrl}`, clientId);
|
|
@@ -2863,14 +2942,18 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
2863
2942
|
const body = entries.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
2864
2943
|
return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
|
|
2865
2944
|
});
|
|
2866
|
-
server.tool("get_idle_seconds", "Returns
|
|
2945
|
+
server.tool("get_idle_seconds", "Returns how long it has been since the user's last keyboard/mouse input. " +
|
|
2867
2946
|
"Call this periodically during long work as a cheap heartbeat — the server " +
|
|
2868
2947
|
"will piggy-back any pending inbox messages in the response, so you stay " +
|
|
2869
|
-
"responsive to the user without having to call poll.
|
|
2870
|
-
"
|
|
2871
|
-
"
|
|
2872
|
-
|
|
2873
|
-
|
|
2948
|
+
"responsive to the user without having to call poll. The answer is either " +
|
|
2949
|
+
"'idle: measured N seconds since the last keyboard/mouse input' or " +
|
|
2950
|
+
"'idle: could not measure — user activity unknown, so activity gating is " +
|
|
2951
|
+
"skipped'. Those are two DIFFERENT states: the first is a reading, the " +
|
|
2952
|
+
"second means this machine cannot report user activity at all and the " +
|
|
2953
|
+
"server therefore delivers instead of gating. Neither is a number to " +
|
|
2954
|
+
"compare against a threshold yourself — the server gates internally.", {}, async () => {
|
|
2955
|
+
const reading = idleReading(getOsIdleSeconds());
|
|
2956
|
+
return { content: [{ type: "text", text: appendInbox(formatIdleReading(reading), sessionTag, clientId) }] };
|
|
2874
2957
|
});
|
|
2875
2958
|
server.tool("get_idle_config", "Returns the server's idle gating policy: { enabled, thresholdSeconds, alwaysDesktopWhenActive }. " +
|
|
2876
2959
|
"Informational only — the server gates internally on every notify call. " +
|
|
@@ -3183,14 +3266,28 @@ function trackReconnect(clientId) {
|
|
|
3183
3266
|
const count = reconnectedClients.length;
|
|
3184
3267
|
const msg = `${count} client${count === 1 ? "" : "s"} reconnected and received updated instructions: ${list}`;
|
|
3185
3268
|
try {
|
|
3186
|
-
await
|
|
3269
|
+
await sendNotificationSummary(msg, "low", "omni-notify-mcp");
|
|
3187
3270
|
}
|
|
3188
3271
|
catch { /* best effort */ }
|
|
3189
3272
|
}, RECONNECT_WINDOW_MS);
|
|
3190
3273
|
}
|
|
3191
3274
|
}
|
|
3192
3275
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
3193
|
-
|
|
3276
|
+
// The Telegram poller needs `getUpdates` over the outbound network and stays
|
|
3277
|
+
// off under NOTIFY_MCP_TEST_ENDPOINTS. The other two have no such dependency:
|
|
3278
|
+
// importCredsOnStart only reads a local file, and the Slack listener is inert
|
|
3279
|
+
// until Slack credentials exist, so a fresh install with populated secrets can
|
|
3280
|
+
// still import them from a TEST_ENDPOINTS process.
|
|
3281
|
+
const TEST_ENDPOINTS = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1";
|
|
3282
|
+
// Bound to EVERY interface, IPv4 and IPv6.
|
|
3283
|
+
//
|
|
3284
|
+
// This was `app.listen(PORT, "0.0.0.0", …)`, which is IPv4-only. On Windows a client that resolves
|
|
3285
|
+
// `localhost` to `::1` first then reaches a socket that is not listening — `[::1]:3737` answers nothing
|
|
3286
|
+
// while `127.0.0.1:3737` is fine — so the MCP endpoint was unreachable for any client whose resolver
|
|
3287
|
+
// prefers IPv6, and VS Code reported "unable to start" for a server that was running correctly. Binding
|
|
3288
|
+
// the hostname instead of the IPv4 wildcard opens both families, so `localhost`, `127.0.0.1` and `::1`
|
|
3289
|
+
// all work and no client has to know which one this process happens to speak.
|
|
3290
|
+
const httpServer = app.listen(PORT, () => {
|
|
3194
3291
|
const ip = getLocalIp();
|
|
3195
3292
|
console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
|
|
3196
3293
|
if (ENABLE_MCP) {
|
|
@@ -3199,9 +3296,12 @@ const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
|
3199
3296
|
else {
|
|
3200
3297
|
console.log(" MCP endpoint (remote) → disabled (set ENABLE_MCP=1 to enable)\n");
|
|
3201
3298
|
}
|
|
3202
|
-
//
|
|
3203
|
-
|
|
3204
|
-
|
|
3299
|
+
// Import runs on EVERY boot path, before the listeners start, so an empty
|
|
3300
|
+
// config.json cannot serve a process whose secrets file IS populated. It
|
|
3301
|
+
// reports the channel names it filled — never a secret value.
|
|
3302
|
+
importCredsOnStart();
|
|
3303
|
+
// Live Telegram/Slack pollers hit real external channels — never under test.
|
|
3304
|
+
if (!TEST_ENDPOINTS) {
|
|
3205
3305
|
startTelegramListener();
|
|
3206
3306
|
startSlackListener();
|
|
3207
3307
|
}
|