shadow-claw 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.well-known/mcp/server-card.json +1 -1
- package/.well-known/mcp.json +1 -1
- package/README.md +96 -278
- package/bin/cli.mjs +4 -0
- package/bin/commands/mcp.mjs +409 -125
- package/bin/commands/mcp.test.mjs +151 -18
- package/bin/utils/control-client.mjs +338 -103
- package/bin/utils/control-client.test.mjs +14 -0
- package/dist/electron/main.cjs +179 -179
- package/dist/lib/components/index.js +21 -21
- package/dist/lib/components/settings/shadow-claw-channel-config.js +1 -1
- package/dist/lib/components/settings/shadow-claw-control-plane.js +1 -1
- package/dist/lib/components/settings/shadow-claw-git.js +1 -1
- package/dist/lib/components/settings/shadow-claw-llm.js +1 -1
- package/dist/lib/components/settings/shadow-claw-networking.js +1 -1
- package/dist/lib/components/settings/shadow-claw-notifications.js +1 -1
- package/dist/lib/components/settings/shadow-claw-peerjs.js +1 -1
- package/dist/lib/components/settings/shadow-claw-storage.js +1 -1
- package/dist/lib/components/settings/shadow-claw-task-server.js +1 -1
- package/dist/lib/components/settings/shadow-claw-webvm.js +1 -1
- package/dist/lib/components/shadow-claw-channel-config.js +1 -1
- package/dist/lib/components/shadow-claw-channels.js +1 -1
- package/dist/lib/components/shadow-claw-chat.js +1 -1
- package/dist/lib/components/shadow-claw-control-plane.js +1 -1
- package/dist/lib/components/shadow-claw-conversations.js +1 -1
- package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
- package/dist/lib/components/shadow-claw-files.js +1 -1
- package/dist/lib/components/shadow-claw-git.js +1 -1
- package/dist/lib/components/shadow-claw-llm.js +1 -1
- package/dist/lib/components/shadow-claw-networking.js +1 -1
- package/dist/lib/components/shadow-claw-notifications.js +1 -1
- package/dist/lib/components/shadow-claw-pages.js +1 -1
- package/dist/lib/components/shadow-claw-peerjs.js +1 -1
- package/dist/lib/components/shadow-claw-settings.js +1 -1
- package/dist/lib/components/shadow-claw-storage.js +1 -1
- package/dist/lib/components/shadow-claw-task-server.js +1 -1
- package/dist/lib/components/shadow-claw-tasks.js +1 -1
- package/dist/lib/components/shadow-claw-terminal.js +1 -1
- package/dist/lib/components/shadow-claw-tools.js +1 -1
- package/dist/lib/components/shadow-claw-webvm.js +1 -1
- package/dist/lib/components/shadow-claw.js +1 -1
- package/dist/lib/{configurePeerJs-D8Ph0-A9.js → configurePeerJs-DG4hliKp.js} +2 -2
- package/dist/lib/{configurePeerJs-D8Ph0-A9.js.map → configurePeerJs-DG4hliKp.js.map} +1 -1
- package/dist/lib/index.js +21 -21
- package/dist/lib/{initControlPlane-BIrWmpzc.js → initControlPlane-DnpAzphg.js} +8 -8
- package/dist/lib/{initControlPlane-BIrWmpzc.js.map → initControlPlane-DnpAzphg.js.map} +1 -1
- package/dist/lib/{markdown-DEUoM1c6.js → markdown-DgdSt2GD.js} +2 -2
- package/dist/lib/{markdown-DEUoM1c6.js.map → markdown-DgdSt2GD.js.map} +1 -1
- package/dist/lib/{orchestrator-CFCW-l1O.js → orchestrator-CjEau_RG.js} +5 -5
- package/dist/lib/{orchestrator-CFCW-l1O.js.map → orchestrator-CjEau_RG.js.map} +1 -1
- package/dist/lib/{orchestrator-BtVj9bBB.js → orchestrator-ClXbJ4Mb.js} +2 -2
- package/dist/lib/{orchestrator-BtVj9bBB.js.map → orchestrator-ClXbJ4Mb.js.map} +1 -1
- package/dist/lib/{peerjs-BoBkx9DJ.js → peerjs-BdfpltIe.js} +3 -3
- package/dist/lib/{peerjs-BoBkx9DJ.js.map → peerjs-BdfpltIe.js.map} +1 -1
- package/dist/lib/{push-client-D6hbzDNV.js → push-client-DjwKknNg.js} +9 -3
- package/dist/lib/push-client-DjwKknNg.js.map +1 -0
- package/dist/lib/{shadow-claw-Cuf2O5bZ.js → shadow-claw-DcPGKenn.js} +4 -4
- package/dist/lib/{shadow-claw-Cuf2O5bZ.js.map → shadow-claw-DcPGKenn.js.map} +1 -1
- package/dist/lib/{shadow-claw-channel-config-BDPKt6Bd.js → shadow-claw-channel-config-DEL-aZ7N.js} +3 -3
- package/dist/lib/{shadow-claw-channel-config-BDPKt6Bd.js.map → shadow-claw-channel-config-DEL-aZ7N.js.map} +1 -1
- package/dist/lib/{shadow-claw-channels-BsP5JABM.js → shadow-claw-channels-BgaJiPdE.js} +2 -2
- package/dist/lib/{shadow-claw-channels-BsP5JABM.js.map → shadow-claw-channels-BgaJiPdE.js.map} +1 -1
- package/dist/lib/{shadow-claw-chat-Cam2lX4k.js → shadow-claw-chat-mXPQZbwT.js} +4 -4
- package/dist/lib/{shadow-claw-chat-Cam2lX4k.js.map → shadow-claw-chat-mXPQZbwT.js.map} +1 -1
- package/dist/lib/{shadow-claw-control-plane-DB2qcCFs.js → shadow-claw-control-plane-DO6foOLV.js} +6 -6
- package/dist/lib/{shadow-claw-control-plane-DB2qcCFs.js.map → shadow-claw-control-plane-DO6foOLV.js.map} +1 -1
- package/dist/lib/{shadow-claw-conversations-C9Vf4XGx.js → shadow-claw-conversations-BLwHTwdW.js} +2 -2
- package/dist/lib/{shadow-claw-conversations-C9Vf4XGx.js.map → shadow-claw-conversations-BLwHTwdW.js.map} +1 -1
- package/dist/lib/{shadow-claw-file-viewer-B4HYiI0v.js → shadow-claw-file-viewer-BSbZvSl-.js} +3 -3
- package/dist/lib/{shadow-claw-file-viewer-B4HYiI0v.js.map → shadow-claw-file-viewer-BSbZvSl-.js.map} +1 -1
- package/dist/lib/{shadow-claw-files-DVROUESO.js → shadow-claw-files-BLVr9Pua.js} +2 -2
- package/dist/lib/{shadow-claw-files-DVROUESO.js.map → shadow-claw-files-BLVr9Pua.js.map} +1 -1
- package/dist/lib/{shadow-claw-git-BqrO7cHJ.js → shadow-claw-git-CHC1H86J.js} +3 -3
- package/dist/lib/{shadow-claw-git-BqrO7cHJ.js.map → shadow-claw-git-CHC1H86J.js.map} +1 -1
- package/dist/lib/{shadow-claw-llm-BexUQdYo.js → shadow-claw-llm-CKXfxd3D.js} +2 -2
- package/dist/lib/{shadow-claw-llm-BexUQdYo.js.map → shadow-claw-llm-CKXfxd3D.js.map} +1 -1
- package/dist/lib/{shadow-claw-networking-CfXRGpUD.js → shadow-claw-networking-dpYvfchh.js} +2 -2
- package/dist/lib/{shadow-claw-networking-CfXRGpUD.js.map → shadow-claw-networking-dpYvfchh.js.map} +1 -1
- package/dist/lib/{shadow-claw-notifications-C9KPMgx_.js → shadow-claw-notifications-_YeAVPga.js} +2 -2
- package/dist/lib/{shadow-claw-notifications-C9KPMgx_.js.map → shadow-claw-notifications-_YeAVPga.js.map} +1 -1
- package/dist/lib/{shadow-claw-pages-CGzGXmrI.js → shadow-claw-pages-DVzZriZ7.js} +3 -3
- package/dist/lib/{shadow-claw-pages-CGzGXmrI.js.map → shadow-claw-pages-DVzZriZ7.js.map} +1 -1
- package/dist/lib/{shadow-claw-peerjs-B0ZB7yhB.js → shadow-claw-peerjs-BvixjeBz.js} +3 -3
- package/dist/lib/{shadow-claw-peerjs-B0ZB7yhB.js.map → shadow-claw-peerjs-BvixjeBz.js.map} +1 -1
- package/dist/lib/{shadow-claw-settings-DBfMZdDS.js → shadow-claw-settings-DV9-gD4Q.js} +2 -2
- package/dist/lib/{shadow-claw-settings-DBfMZdDS.js.map → shadow-claw-settings-DV9-gD4Q.js.map} +1 -1
- package/dist/lib/{shadow-claw-storage-CM-w6mgG.js → shadow-claw-storage-Bv__kXG6.js} +2 -2
- package/dist/lib/{shadow-claw-storage-CM-w6mgG.js.map → shadow-claw-storage-Bv__kXG6.js.map} +1 -1
- package/dist/lib/{shadow-claw-task-server-DV4b1g2-.js → shadow-claw-task-server-DNOe8PWK.js} +2 -2
- package/dist/lib/{shadow-claw-task-server-DV4b1g2-.js.map → shadow-claw-task-server-DNOe8PWK.js.map} +1 -1
- package/dist/lib/{shadow-claw-tasks-K-foOHSE.js → shadow-claw-tasks-qmcDT4PU.js} +3 -3
- package/dist/lib/{shadow-claw-tasks-K-foOHSE.js.map → shadow-claw-tasks-qmcDT4PU.js.map} +1 -1
- package/dist/lib/{shadow-claw-terminal-CpAknKyA.js → shadow-claw-terminal-0QUWP8of.js} +2 -2
- package/dist/lib/{shadow-claw-terminal-CpAknKyA.js.map → shadow-claw-terminal-0QUWP8of.js.map} +1 -1
- package/dist/lib/{shadow-claw-tools-D98XZ0Ep.js → shadow-claw-tools-Bs_bmI6-.js} +3 -3
- package/dist/lib/{shadow-claw-tools-D98XZ0Ep.js.map → shadow-claw-tools-Bs_bmI6-.js.map} +1 -1
- package/dist/lib/{shadow-claw-webvm-DLOb8dMI.js → shadow-claw-webvm-DYaoL7QY.js} +2 -2
- package/dist/lib/{shadow-claw-webvm-DLOb8dMI.js.map → shadow-claw-webvm-DYaoL7QY.js.map} +1 -1
- package/dist/lib/{syncWebMcpRegistration-NspKefOI.js → syncWebMcpRegistration-BhbqI6R2.js} +2 -2
- package/dist/lib/{syncWebMcpRegistration-NspKefOI.js.map → syncWebMcpRegistration-BhbqI6R2.js.map} +1 -1
- package/dist/public/.well-known/mcp/server-card.json +1 -1
- package/dist/public/.well-known/mcp.json +1 -1
- package/dist/public/AGENTS.md +22 -3
- package/dist/public/README.md +96 -278
- package/dist/public/agent.worker.js +50 -50
- package/dist/public/{base-task-model-CYkpwnvU-DDfHUUIS.js → base-task-model-CYkpwnvU-DrXr4G1R.js} +1 -1
- package/dist/public/{configurePeerJs-BhBokQlC.js → configurePeerJs-B470pdeU.js} +1 -1
- package/dist/public/docs/README.md +31 -31
- package/dist/public/docs/architecture/context-management.md +7 -0
- package/dist/public/docs/architecture/storage.md +10 -1
- package/dist/public/docs/decisions/bundled-typescript-architecture.md +0 -1
- package/dist/public/docs/decisions/native-web-components-and-signals.md +0 -1
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/guides/publishing-to-github-pages.md +334 -0
- package/dist/public/docs/guides/server-development-configuration.md +15 -0
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/subsystems/cli.md +10 -2
- package/dist/public/docs/subsystems/control-plane.md +9 -8
- package/dist/public/docs/subsystems/crypto.md +9 -0
- package/dist/public/docs/subsystems/custom-element-security.md +2 -0
- package/dist/public/docs/subsystems/mcp-server.md +31 -16
- package/dist/public/docs/subsystems/notifications.md +8 -5
- package/dist/public/docs/subsystems/providers.md +9 -0
- package/dist/public/docs/subsystems/share-target.md +107 -0
- package/dist/public/docs/subsystems/webmcp.md +43 -0
- package/dist/public/e2e/README.md +4 -2
- package/dist/public/{e2e-bridge-DcBoeFvd.js → e2e-bridge-CSW0S5Yz.js} +1 -1
- package/dist/public/{file-viewer-CKtVC9Ix.js → file-viewer-Cs-riNvy.js} +1 -1
- package/dist/public/{firebase-OSDX3viP.js → firebase-cwAl_ABm.js} +1 -1
- package/dist/public/{git-CkhCJonj.js → git-GD09WATp.js} +25 -25
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/{initControlPlane-Bi8ouFEb.js → initControlPlane-BqCY0NQy.js} +1 -1
- package/dist/public/initControlPlane-C-IXaKYX.js +1 -0
- package/dist/public/{language-detector-BRKrlra0.js → language-detector-CIeEuQFk.js} +1 -1
- package/dist/public/llms.txt +4 -2
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/{markdown-C-hCVJtc.js → markdown-Cw7vCtTg.js} +1 -1
- package/dist/public/{openai-Bpd71Oja.js → openai-BJIb2c5j.js} +3 -3
- package/dist/public/{orchestrator-8qNGfw8r.js → orchestrator-BWkHHO9Y.js} +1 -1
- package/dist/public/{orchestrator-BT3Ks5ip.js → orchestrator-DpkkeD6T.js} +1 -1
- package/dist/public/{peerjs-DfhwEiiq.js → peerjs-tf2EmWjB.js} +1 -1
- package/dist/public/{prompt-api-polyfill-Dl1kbJhR.js → prompt-api-polyfill-CXd75leW.js} +1 -1
- package/dist/public/{push-client-BVLrE9pK.js → push-client-CHjSbrWs.js} +1 -1
- package/dist/public/{rewriter-j6D9LDER.js → rewriter-BEHn9gll.js} +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-DCjxNDCX.js → shadow-claw-BBPV_9X8.js} +2 -2
- package/dist/public/{shadow-claw-channels-CITDrqMl.js → shadow-claw-channels-BJvrKQjg.js} +1 -1
- package/dist/public/{shadow-claw-chat-BQrSgSKF.js → shadow-claw-chat-BG2EaRaw.js} +1 -1
- package/dist/public/{shadow-claw-control-plane-Cp001OY0.js → shadow-claw-control-plane-BBa7WbTg.js} +2 -2
- package/dist/public/{shadow-claw-conversations-EhX4RvIj.js → shadow-claw-conversations-CFaWFmkm.js} +1 -1
- package/dist/public/{shadow-claw-file-viewer-DiQXIjZD.js → shadow-claw-file-viewer-Z-GIKaE9.js} +1 -1
- package/dist/public/{shadow-claw-files-lkTnSJsn.js → shadow-claw-files-BBx4iMsk.js} +1 -1
- package/dist/public/{shadow-claw-git-BlcA_z8x.js → shadow-claw-git-vTOKatwd.js} +2 -2
- package/dist/public/{shadow-claw-llm-CW16X9Rg.js → shadow-claw-llm-Ce1Ax6-x.js} +1 -1
- package/dist/public/{shadow-claw-networking-ySsMBY_i.js → shadow-claw-networking-q4XIzdB5.js} +1 -1
- package/dist/public/{shadow-claw-notifications-CyV2zCQ8.js → shadow-claw-notifications-D2cz0gmm.js} +1 -1
- package/dist/public/{shadow-claw-pages-Cx7cF9ah.js → shadow-claw-pages-3335lmSC.js} +1 -1
- package/dist/public/{shadow-claw-settings-zm4PHHGi.js → shadow-claw-settings-4ptYuJwO.js} +2 -2
- package/dist/public/{shadow-claw-storage-dy9JAEGd.js → shadow-claw-storage-bIrPKFhF.js} +1 -1
- package/dist/public/{shadow-claw-task-server-Bleww48s.js → shadow-claw-task-server-5yqVBxy1.js} +1 -1
- package/dist/public/{shadow-claw-tasks-CTI5oaLN.js → shadow-claw-tasks-DcqYHPLZ.js} +1 -1
- package/dist/public/{shadow-claw-terminal-mZ3i4HaQ.js → shadow-claw-terminal-fARaDONr.js} +1 -1
- package/dist/public/{shadow-claw-tools-B_nuk8Zl.js → shadow-claw-tools-paEAhcJr.js} +1 -1
- package/dist/public/{shadow-claw-webvm-Vl_jH5YL.js → shadow-claw-webvm-CbzH_vyX.js} +1 -1
- package/dist/public/{summarizer-VJNr-7xG.js → summarizer-BT3AuFed.js} +1 -1
- package/dist/public/{syncWebMcpRegistration-n2vAMJ4w.js → syncWebMcpRegistration-CWSYaHex.js} +1 -1
- package/dist/public/{transformers-BhQMWQG5.js → transformers-1VTxqKjT.js} +1 -1
- package/dist/public/{translator-DEQXb7ZL.js → translator-B50RYlFt.js} +1 -1
- package/dist/public/{webllm-CHN5H-Sy.js → webllm-FmyJWWpt.js} +1 -1
- package/dist/public/{writer-CD9oN2pq.js → writer-Bhb4YYRE.js} +1 -1
- package/dist/server.js +205 -203
- package/package.json +1 -1
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +2 -0
- package/src/core/orchestrator/utils/configurePeerJs.test.ts +2 -0
- package/src/server/client-registry.test.ts +9 -0
- package/src/server/client-registry.ts +14 -0
- package/src/server/mcp/mcp-integration.test.ts +68 -16
- package/src/server/mcp/tools/built-in-tools.ts +125 -34
- package/src/server/mcp/tools/client-tool-relay.ts +81 -44
- package/src/server/mcp/tools/mcp-tools.test.ts +103 -13
- package/src/server/mcp/types.ts +3 -0
- package/src/server/openapi/paths/push.ts +48 -0
- package/src/server/server.ts +30 -0
- package/src/subsystems/notifications/push-client.test.ts +17 -0
- package/src/subsystems/notifications/push-client.ts +22 -2
- package/src/subsystems/notifications/push-routes.test.ts +31 -0
- package/src/subsystems/notifications/push-routes.ts +10 -0
- package/src/subsystems/notifications/push-store.test.ts +49 -0
- package/src/subsystems/notifications/push-store.ts +45 -0
- package/src/worker/worker.test.ts +2 -0
- package/dist/lib/push-client-D6hbzDNV.js.map +0 -1
|
@@ -7,8 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { McpServer } from "../mcp-server.js";
|
|
10
|
-
import
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
MCP_CLIENT_TOOL_PREFIX,
|
|
12
|
+
type McpTool,
|
|
13
|
+
type McpToolCallResult,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
import {
|
|
16
|
+
resolveTargetClientId,
|
|
17
|
+
getDynamicSendNotificationTool,
|
|
18
|
+
} from "./built-in-tools.js";
|
|
19
|
+
|
|
20
|
+
function getClientRawToolName(name: string): string {
|
|
21
|
+
return name.startsWith(MCP_CLIENT_TOOL_PREFIX)
|
|
22
|
+
? name.slice(MCP_CLIENT_TOOL_PREFIX.length)
|
|
23
|
+
: name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toClientExposedToolName(name: string): string {
|
|
27
|
+
return !name.startsWith(MCP_CLIENT_TOOL_PREFIX)
|
|
28
|
+
? `${MCP_CLIENT_TOOL_PREFIX}${name}`
|
|
29
|
+
: name;
|
|
30
|
+
}
|
|
12
31
|
|
|
13
32
|
export interface ClientToolRelayOptions {
|
|
14
33
|
targetClientId?: string;
|
|
@@ -51,46 +70,44 @@ export function createClientToolRelay(
|
|
|
51
70
|
: [];
|
|
52
71
|
} catch (_) {}
|
|
53
72
|
|
|
54
|
-
if (!Array.isArray(clients) || clients.length === 0) {
|
|
55
|
-
toolSupportingClientsMap.clear();
|
|
56
|
-
toolDefMap.clear();
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
73
|
toolSupportingClientsMap.clear();
|
|
61
74
|
toolDefMap.clear();
|
|
62
75
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
t.name
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
if (Array.isArray(clients) && clients.length > 0) {
|
|
77
|
+
for (const c of clients) {
|
|
78
|
+
const cid = (c as any).clientId || (c as any).id;
|
|
79
|
+
if (!cid) continue;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const res = await controlPlane.sendCommand(cid, "list-tools", {});
|
|
83
|
+
if (
|
|
84
|
+
res &&
|
|
85
|
+
res.success &&
|
|
86
|
+
res.data?.tools &&
|
|
87
|
+
Array.isArray(res.data.tools)
|
|
88
|
+
) {
|
|
89
|
+
for (const t of res.data.tools) {
|
|
90
|
+
const rawName = getClientRawToolName(t.name);
|
|
91
|
+
if (
|
|
92
|
+
rawName === "send_notification" ||
|
|
93
|
+
(rawName.startsWith("shadowclaw_") &&
|
|
94
|
+
!rawName.startsWith("shadowclaw_client_"))
|
|
95
|
+
) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!toolDefMap.has(rawName)) {
|
|
99
|
+
toolDefMap.set(rawName, t);
|
|
100
|
+
toolSupportingClientsMap.set(rawName, []);
|
|
101
|
+
}
|
|
102
|
+
toolSupportingClientsMap.get(rawName)!.push(c);
|
|
85
103
|
}
|
|
86
|
-
toolSupportingClientsMap.get(t.name)!.push(c);
|
|
87
104
|
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.warn(
|
|
107
|
+
`[ClientToolRelay] Failed to query tools from client ${cid}:`,
|
|
108
|
+
err,
|
|
109
|
+
);
|
|
88
110
|
}
|
|
89
|
-
} catch (err) {
|
|
90
|
-
console.warn(
|
|
91
|
-
`[ClientToolRelay] Failed to query tools from client ${cid}:`,
|
|
92
|
-
err,
|
|
93
|
-
);
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
113
|
|
|
@@ -100,8 +117,8 @@ export function createClientToolRelay(
|
|
|
100
117
|
);
|
|
101
118
|
const toolMap = new Map<string, McpTool>();
|
|
102
119
|
|
|
103
|
-
for (const [
|
|
104
|
-
const supportingClients = toolSupportingClientsMap.get(
|
|
120
|
+
for (const [rawToolName, t] of toolDefMap.entries()) {
|
|
121
|
+
const supportingClients = toolSupportingClientsMap.get(rawToolName) || [];
|
|
105
122
|
const supportingClientIds = supportingClients
|
|
106
123
|
.map((cl: any) => cl.clientId || cl.id)
|
|
107
124
|
.filter(Boolean);
|
|
@@ -135,10 +152,13 @@ export function createClientToolRelay(
|
|
|
135
152
|
: ` [Client: ${toolDefaultLabel} (${toolDefaultId.slice(0, 14)}...)]`
|
|
136
153
|
: "";
|
|
137
154
|
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
const exposedName = toClientExposedToolName(rawToolName);
|
|
156
|
+
|
|
157
|
+
toolMap.set(exposedName, {
|
|
158
|
+
name: exposedName,
|
|
140
159
|
description:
|
|
141
|
-
(t.description ||
|
|
160
|
+
(t.description ||
|
|
161
|
+
`Relayed tool '${rawToolName}' from connected client.`) +
|
|
142
162
|
clientNote,
|
|
143
163
|
inputSchema: {
|
|
144
164
|
...existingSchema,
|
|
@@ -149,6 +169,9 @@ export function createClientToolRelay(
|
|
|
149
169
|
});
|
|
150
170
|
}
|
|
151
171
|
|
|
172
|
+
const dynamicNotifTool = getDynamicSendNotificationTool(controlPlane);
|
|
173
|
+
toolMap.set(dynamicNotifTool.name, dynamicNotifTool);
|
|
174
|
+
|
|
152
175
|
cachedTools = Array.from(toolMap.values());
|
|
153
176
|
cacheTime = now;
|
|
154
177
|
return cachedTools;
|
|
@@ -168,6 +191,16 @@ export function createClientToolRelay(
|
|
|
168
191
|
},
|
|
169
192
|
);
|
|
170
193
|
}
|
|
194
|
+
const rawName = getClientRawToolName(tool.name);
|
|
195
|
+
if (!registeredHandlers.has(rawName)) {
|
|
196
|
+
registeredHandlers.add(rawName);
|
|
197
|
+
server.registerToolHandler(
|
|
198
|
+
rawName,
|
|
199
|
+
async (args, meta, inputResponses) => {
|
|
200
|
+
return executeRelayedTool(rawName, args, meta, inputResponses);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
}
|
|
171
204
|
}
|
|
172
205
|
return tools;
|
|
173
206
|
});
|
|
@@ -202,7 +235,11 @@ export function createClientToolRelay(
|
|
|
202
235
|
await discoverTools();
|
|
203
236
|
}
|
|
204
237
|
|
|
205
|
-
const
|
|
238
|
+
const rawToolName = getClientRawToolName(toolName);
|
|
239
|
+
const supportingClients =
|
|
240
|
+
toolSupportingClientsMap.get(rawToolName) ||
|
|
241
|
+
toolSupportingClientsMap.get(toolName) ||
|
|
242
|
+
[];
|
|
206
243
|
const supportingClientIds = supportingClients
|
|
207
244
|
.map((cl: any) => cl.clientId || cl.id)
|
|
208
245
|
.filter(Boolean);
|
|
@@ -253,7 +290,7 @@ export function createClientToolRelay(
|
|
|
253
290
|
}
|
|
254
291
|
|
|
255
292
|
// Interactive tool handling: ask_user via MRTR
|
|
256
|
-
if (toolName === "ask_user") {
|
|
293
|
+
if (rawToolName === "ask_user" || toolName === "ask_user") {
|
|
257
294
|
if (!inputResponses || !inputResponses["response"]) {
|
|
258
295
|
const question =
|
|
259
296
|
(args.question as string) ||
|
|
@@ -302,7 +339,7 @@ export function createClientToolRelay(
|
|
|
302
339
|
const toolArgs = { ...args };
|
|
303
340
|
delete toolArgs.clientId;
|
|
304
341
|
const res = await controlPlane.sendCommand(clientId, "invoke-tool", {
|
|
305
|
-
toolName,
|
|
342
|
+
toolName: rawToolName,
|
|
306
343
|
input: toolArgs,
|
|
307
344
|
});
|
|
308
345
|
|
|
@@ -36,15 +36,17 @@ describe("ShadowClaw Built-in MCP Tools", () => {
|
|
|
36
36
|
registerBuiltInTools(server, mockControlPlane);
|
|
37
37
|
|
|
38
38
|
const toolNames = SHADOWCLAW_BUILTIN_TOOLS.map((t) => t.name);
|
|
39
|
-
expect(toolNames).toContain("
|
|
40
|
-
expect(toolNames).toContain("
|
|
41
|
-
expect(toolNames).toContain("
|
|
42
|
-
expect(toolNames).toContain("
|
|
43
|
-
expect(toolNames).toContain("
|
|
44
|
-
expect(toolNames).toContain("
|
|
39
|
+
expect(toolNames).toContain("shadowclaw_server_list_clients");
|
|
40
|
+
expect(toolNames).toContain("shadowclaw_server_send_message");
|
|
41
|
+
expect(toolNames).toContain("shadowclaw_server_read_state");
|
|
42
|
+
expect(toolNames).toContain("shadowclaw_server_list_tasks");
|
|
43
|
+
expect(toolNames).toContain("shadowclaw_server_manage_backup");
|
|
44
|
+
expect(toolNames).toContain("shadowclaw_server_set_active_client");
|
|
45
|
+
expect(toolNames).toContain("shadowclaw_server_status");
|
|
46
|
+
expect(toolNames).toContain("shadowclaw_server_send_notification");
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
it("executes
|
|
49
|
+
it("executes shadowclaw_server_list_clients", async () => {
|
|
48
50
|
const server = new McpServer();
|
|
49
51
|
const mockControlPlane: any = {
|
|
50
52
|
getConnectedClients: jest.fn().mockReturnValue([
|
|
@@ -65,7 +67,7 @@ describe("ShadowClaw Built-in MCP Tools", () => {
|
|
|
65
67
|
id: 1,
|
|
66
68
|
method: "tools/call",
|
|
67
69
|
params: {
|
|
68
|
-
name: "
|
|
70
|
+
name: "shadowclaw_server_list_clients",
|
|
69
71
|
arguments: {},
|
|
70
72
|
},
|
|
71
73
|
});
|
|
@@ -77,6 +79,21 @@ describe("ShadowClaw Built-in MCP Tools", () => {
|
|
|
77
79
|
expect(text).toContain("client-abc");
|
|
78
80
|
expect(text).toContain("MacBook Chrome");
|
|
79
81
|
}
|
|
82
|
+
|
|
83
|
+
// Legacy alias also works
|
|
84
|
+
const resAlias = await server.handleRequest({
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
id: 11,
|
|
87
|
+
method: "tools/call",
|
|
88
|
+
params: {
|
|
89
|
+
name: "shadowclaw_list_clients",
|
|
90
|
+
arguments: {},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(resAlias).not.toBeNull();
|
|
94
|
+
if (resAlias && "result" in resAlias) {
|
|
95
|
+
expect(resAlias.result.content[0].text).toContain("client-abc");
|
|
96
|
+
}
|
|
80
97
|
});
|
|
81
98
|
|
|
82
99
|
it("executes shadowclaw_send_message to active client", async () => {
|
|
@@ -481,6 +498,60 @@ describe("ShadowClaw Built-in MCP Tools", () => {
|
|
|
481
498
|
closePushStore();
|
|
482
499
|
}
|
|
483
500
|
});
|
|
501
|
+
|
|
502
|
+
it("dynamically populates clientId enum and description for shadowclaw_server_send_notification when push clients are registered", async () => {
|
|
503
|
+
openPushStore(":memory:");
|
|
504
|
+
try {
|
|
505
|
+
saveSubscription({
|
|
506
|
+
endpoint: "https://fcm.googleapis.com/fcm/send/device1",
|
|
507
|
+
keys: { p256dh: "k1", auth: "a1" },
|
|
508
|
+
clientId: "client-push-001",
|
|
509
|
+
deviceLabel: "Pixel 9 Pro",
|
|
510
|
+
});
|
|
511
|
+
saveSubscription({
|
|
512
|
+
endpoint: "https://fcm.googleapis.com/fcm/send/device2",
|
|
513
|
+
keys: { p256dh: "k2", auth: "a2" },
|
|
514
|
+
clientId: "client-push-002",
|
|
515
|
+
deviceLabel: "MacBook Air",
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const server = new McpServer();
|
|
519
|
+
const mockControlPlane: any = {
|
|
520
|
+
getConnectedClients: jest.fn().mockReturnValue([]),
|
|
521
|
+
};
|
|
522
|
+
registerBuiltInTools(server, mockControlPlane);
|
|
523
|
+
const relay = createClientToolRelay(mockControlPlane);
|
|
524
|
+
relay.attachToServer(server);
|
|
525
|
+
|
|
526
|
+
const res = await server.handleRequest({
|
|
527
|
+
jsonrpc: "2.0",
|
|
528
|
+
id: 99,
|
|
529
|
+
method: "tools/list",
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
expect(res).not.toBeNull();
|
|
533
|
+
if (res && "result" in res) {
|
|
534
|
+
const notifTool = res.result.tools.find(
|
|
535
|
+
(t: any) => t.name === "shadowclaw_server_send_notification",
|
|
536
|
+
);
|
|
537
|
+
expect(notifTool).toBeDefined();
|
|
538
|
+
const clientIdProp = notifTool.inputSchema.properties.clientId;
|
|
539
|
+
expect(clientIdProp).toBeDefined();
|
|
540
|
+
expect(clientIdProp.enum).toEqual([
|
|
541
|
+
"client-push-001",
|
|
542
|
+
"client-push-002",
|
|
543
|
+
]);
|
|
544
|
+
expect(clientIdProp.description).toContain(
|
|
545
|
+
"client-push-001 (Pixel 9 Pro)",
|
|
546
|
+
);
|
|
547
|
+
expect(clientIdProp.description).toContain(
|
|
548
|
+
"client-push-002 (MacBook Air)",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
} finally {
|
|
552
|
+
closePushStore();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
484
555
|
});
|
|
485
556
|
|
|
486
557
|
describe("Client Tool Relay", () => {
|
|
@@ -528,7 +599,9 @@ describe("Client Tool Relay", () => {
|
|
|
528
599
|
|
|
529
600
|
// Tools list should discover relayed tool
|
|
530
601
|
const tools = await server.getTools();
|
|
531
|
-
const readFileTool = tools.find(
|
|
602
|
+
const readFileTool = tools.find(
|
|
603
|
+
(t) => t.name === "shadowclaw_client_read_file",
|
|
604
|
+
);
|
|
532
605
|
expect(readFileTool).toBeDefined();
|
|
533
606
|
expect(readFileTool?.description).toBe("Read workspace file");
|
|
534
607
|
|
|
@@ -538,7 +611,7 @@ describe("Client Tool Relay", () => {
|
|
|
538
611
|
id: 3,
|
|
539
612
|
method: "tools/call",
|
|
540
613
|
params: {
|
|
541
|
-
name: "
|
|
614
|
+
name: "shadowclaw_client_read_file",
|
|
542
615
|
arguments: { path: "notes.txt" },
|
|
543
616
|
},
|
|
544
617
|
});
|
|
@@ -548,6 +621,21 @@ describe("Client Tool Relay", () => {
|
|
|
548
621
|
expect(res.result.resultType).toBe("complete");
|
|
549
622
|
expect(res.result.content[0].text).toContain("File content here");
|
|
550
623
|
}
|
|
624
|
+
|
|
625
|
+
// Calling via unprefixed name also works as backward-compatible alias
|
|
626
|
+
const resAlias = await server.handleRequest({
|
|
627
|
+
jsonrpc: "2.0",
|
|
628
|
+
id: 31,
|
|
629
|
+
method: "tools/call",
|
|
630
|
+
params: {
|
|
631
|
+
name: "read_file",
|
|
632
|
+
arguments: { path: "notes.txt" },
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
expect(resAlias).not.toBeNull();
|
|
636
|
+
if (resAlias && "result" in resAlias) {
|
|
637
|
+
expect(resAlias.result.content[0].text).toContain("File content here");
|
|
638
|
+
}
|
|
551
639
|
});
|
|
552
640
|
|
|
553
641
|
it("handles ask_user interactive MRTR loop via client relay", async () => {
|
|
@@ -802,7 +890,9 @@ describe("Client Tool Relay", () => {
|
|
|
802
890
|
|
|
803
891
|
// 1. Discover tools across multi-client: schema should have clientId enum
|
|
804
892
|
const tools = await server.getTools();
|
|
805
|
-
const listFilesTool = tools.find(
|
|
893
|
+
const listFilesTool = tools.find(
|
|
894
|
+
(t) => t.name === "shadowclaw_client_list_files",
|
|
895
|
+
);
|
|
806
896
|
expect(listFilesTool).toBeDefined();
|
|
807
897
|
const props = listFilesTool?.inputSchema.properties as any;
|
|
808
898
|
expect(props?.clientId).toBeDefined();
|
|
@@ -828,7 +918,7 @@ describe("Client Tool Relay", () => {
|
|
|
828
918
|
id: 21,
|
|
829
919
|
method: "tools/call",
|
|
830
920
|
params: {
|
|
831
|
-
name: "
|
|
921
|
+
name: "shadowclaw_client_list_files",
|
|
832
922
|
arguments: { clientId: "c1" },
|
|
833
923
|
},
|
|
834
924
|
});
|
|
@@ -843,7 +933,7 @@ describe("Client Tool Relay", () => {
|
|
|
843
933
|
id: 22,
|
|
844
934
|
method: "tools/call",
|
|
845
935
|
params: {
|
|
846
|
-
name: "
|
|
936
|
+
name: "shadowclaw_client_list_files",
|
|
847
937
|
arguments: {},
|
|
848
938
|
},
|
|
849
939
|
});
|
package/src/server/mcp/types.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [
|
|
|
19
19
|
export type SupportedProtocolVersion =
|
|
20
20
|
(typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
|
|
21
21
|
|
|
22
|
+
export const MCP_CLIENT_TOOL_PREFIX = "shadowclaw_client_";
|
|
23
|
+
export const MCP_SERVER_TOOL_PREFIX = "shadowclaw_server_";
|
|
24
|
+
|
|
22
25
|
// Standard JSON-RPC and MCP error codes
|
|
23
26
|
export const MCP_ERROR_CODES = {
|
|
24
27
|
ParseError: -32700,
|
|
@@ -101,6 +101,54 @@ export const pushPaths: Record<string, OpenApiPathItem> = {
|
|
|
101
101
|
},
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
|
+
"/push/clients": {
|
|
105
|
+
get: {
|
|
106
|
+
tags: ["Push Notifications"],
|
|
107
|
+
summary: "List Registered Push Clients",
|
|
108
|
+
description:
|
|
109
|
+
"Retrieves unique registered clients that have active Web Push subscriptions, including device labels and subscription counts.",
|
|
110
|
+
responses: {
|
|
111
|
+
"200": {
|
|
112
|
+
description: "List of registered push clients",
|
|
113
|
+
content: {
|
|
114
|
+
"application/json": {
|
|
115
|
+
schema: {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
clients: {
|
|
119
|
+
type: "array",
|
|
120
|
+
items: {
|
|
121
|
+
type: "object",
|
|
122
|
+
required: ["clientId"],
|
|
123
|
+
properties: {
|
|
124
|
+
clientId: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Unique client ID",
|
|
127
|
+
},
|
|
128
|
+
deviceLabel: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "Client device label",
|
|
131
|
+
},
|
|
132
|
+
subscriptionCount: {
|
|
133
|
+
type: "integer",
|
|
134
|
+
description: "Number of active push subscriptions",
|
|
135
|
+
},
|
|
136
|
+
lastSeen: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description:
|
|
139
|
+
"Timestamp of latest subscription creation",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
104
152
|
"/push/send": {
|
|
105
153
|
post: {
|
|
106
154
|
tags: ["Push Notifications"],
|
package/src/server/server.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { exit } from "node:process";
|
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import https from "node:https";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
8
|
import tcpPortUsed from "tcp-port-used";
|
|
7
9
|
|
|
@@ -116,6 +118,34 @@ export async function startServer(
|
|
|
116
118
|
`Control plane active at ${protocol}://${config.bindHost}:${config.port}/api/control/events (SSE) and ${wsProtocol}://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
|
|
117
119
|
);
|
|
118
120
|
console.log(`Control token: ${controlPlane.getToken()}`);
|
|
121
|
+
try {
|
|
122
|
+
const tmpDir = path.join(tmpdir(), "shadow-claw");
|
|
123
|
+
if (!fs.existsSync(tmpDir)) {
|
|
124
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
const tokenFilePayload =
|
|
127
|
+
JSON.stringify(
|
|
128
|
+
{
|
|
129
|
+
token: controlPlane.getToken(),
|
|
130
|
+
port: config.port,
|
|
131
|
+
bindHost: config.bindHost,
|
|
132
|
+
createdAt: Date.now(),
|
|
133
|
+
createdAtIso: new Date().toISOString(),
|
|
134
|
+
},
|
|
135
|
+
null,
|
|
136
|
+
2,
|
|
137
|
+
) + "\n";
|
|
138
|
+
fs.writeFileSync(
|
|
139
|
+
path.join(tmpDir, `control-token-${config.port}.json`),
|
|
140
|
+
tokenFilePayload,
|
|
141
|
+
"utf8",
|
|
142
|
+
);
|
|
143
|
+
fs.writeFileSync(
|
|
144
|
+
path.join(tmpDir, "control-token.json"),
|
|
145
|
+
tokenFilePayload,
|
|
146
|
+
"utf8",
|
|
147
|
+
);
|
|
148
|
+
} catch (_) {}
|
|
119
149
|
console.log(
|
|
120
150
|
`MCP endpoint active at ${protocol}://${config.bindHost}:${config.port}/mcp`,
|
|
121
151
|
);
|
|
@@ -187,6 +187,23 @@ describe("push-client", () => {
|
|
|
187
187
|
expect(body.clientId).toBe("client-explicit-123");
|
|
188
188
|
expect(body.deviceLabel).toBe("Pixel 9 Pro");
|
|
189
189
|
});
|
|
190
|
+
|
|
191
|
+
it("automatically resolves clientId and deviceLabel if omitted", async () => {
|
|
192
|
+
(fetch as any)
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
ok: true,
|
|
195
|
+
json: () => Promise.resolve({ publicKey: "test-vapid-key" }),
|
|
196
|
+
})
|
|
197
|
+
.mockResolvedValueOnce({ ok: true });
|
|
198
|
+
|
|
199
|
+
await subscribeToPush();
|
|
200
|
+
const postCall = (fetch as any).mock.calls[1];
|
|
201
|
+
const body = JSON.parse(postCall[1].body);
|
|
202
|
+
expect(body.clientId).toBeDefined();
|
|
203
|
+
expect(typeof body.clientId).toBe("string");
|
|
204
|
+
expect(body.deviceLabel).toBeDefined();
|
|
205
|
+
expect(typeof body.deviceLabel).toBe("string");
|
|
206
|
+
});
|
|
190
207
|
});
|
|
191
208
|
|
|
192
209
|
describe("unsubscribeFromPush", () => {
|
|
@@ -106,12 +106,32 @@ export async function subscribeToPush(
|
|
|
106
106
|
applicationServerKey: urlBase64ToUint8Array(publicKey) as any,
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
let resolvedClientId =
|
|
110
110
|
clientId ||
|
|
111
111
|
(typeof localStorage !== "undefined"
|
|
112
112
|
? localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_CLIENT_ID) || undefined
|
|
113
113
|
: undefined);
|
|
114
114
|
|
|
115
|
+
let resolvedDeviceLabel = deviceLabel;
|
|
116
|
+
|
|
117
|
+
if (!resolvedClientId || !resolvedDeviceLabel) {
|
|
118
|
+
try {
|
|
119
|
+
const initCp = await import("../../core/utils/initControlPlane.js");
|
|
120
|
+
if (
|
|
121
|
+
!resolvedClientId &&
|
|
122
|
+
typeof initCp.getOrCreateControlPlaneClientId === "function"
|
|
123
|
+
) {
|
|
124
|
+
resolvedClientId = initCp.getOrCreateControlPlaneClientId();
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
!resolvedDeviceLabel &&
|
|
128
|
+
typeof initCp.detectDeviceLabel === "function"
|
|
129
|
+
) {
|
|
130
|
+
resolvedDeviceLabel = initCp.detectDeviceLabel();
|
|
131
|
+
}
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
const subJson =
|
|
116
136
|
typeof subscription.toJSON === "function"
|
|
117
137
|
? subscription.toJSON()
|
|
@@ -126,7 +146,7 @@ export async function subscribeToPush(
|
|
|
126
146
|
body: JSON.stringify({
|
|
127
147
|
...subJson,
|
|
128
148
|
...(resolvedClientId ? { clientId: resolvedClientId } : {}),
|
|
129
|
-
...(
|
|
149
|
+
...(resolvedDeviceLabel ? { deviceLabel: resolvedDeviceLabel } : {}),
|
|
130
150
|
}),
|
|
131
151
|
}),
|
|
132
152
|
);
|
|
@@ -25,6 +25,7 @@ jest.unstable_mockModule("./push-store.js", () => ({
|
|
|
25
25
|
getAllSubscriptions: jest.fn(() => []),
|
|
26
26
|
getSubscriptionsByClientId: jest.fn(() => []),
|
|
27
27
|
findSubscriptionsForClient: jest.fn(() => []),
|
|
28
|
+
getRegisteredPushClients: jest.fn(() => []),
|
|
28
29
|
}));
|
|
29
30
|
|
|
30
31
|
const { registerPushRoutes, broadcastPush } = await import("./push-routes.js");
|
|
@@ -179,6 +180,36 @@ describe("push-routes", () => {
|
|
|
179
180
|
});
|
|
180
181
|
});
|
|
181
182
|
|
|
183
|
+
describe("GET /push/clients", () => {
|
|
184
|
+
it("registers the route", () => {
|
|
185
|
+
expect(app.routes.get["/push/clients"]).toBeDefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns registered push clients", async () => {
|
|
189
|
+
(store.getRegisteredPushClients as any).mockReturnValue([
|
|
190
|
+
{
|
|
191
|
+
clientId: "client-abc",
|
|
192
|
+
deviceLabel: "Mac Chrome",
|
|
193
|
+
subscriptionCount: 1,
|
|
194
|
+
lastSeen: "2026-09-01",
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
const req = createMockReq();
|
|
198
|
+
const res = createMockRes();
|
|
199
|
+
await app.routes.get["/push/clients"](req, res);
|
|
200
|
+
expect(res._json).toEqual({
|
|
201
|
+
clients: [
|
|
202
|
+
{
|
|
203
|
+
clientId: "client-abc",
|
|
204
|
+
deviceLabel: "Mac Chrome",
|
|
205
|
+
subscriptionCount: 1,
|
|
206
|
+
lastSeen: "2026-09-01",
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
182
213
|
describe("POST /push/send", () => {
|
|
183
214
|
it("registers the route", () => {
|
|
184
215
|
expect(app.routes.post["/push/send"]).toBeDefined();
|
|
@@ -16,10 +16,14 @@ import {
|
|
|
16
16
|
getSubscription,
|
|
17
17
|
getAllSubscriptions,
|
|
18
18
|
findSubscriptionsForClient,
|
|
19
|
+
getRegisteredPushClients,
|
|
19
20
|
type PushSubscriptionRow,
|
|
21
|
+
type PushClientRecord,
|
|
20
22
|
} from "./push-store.js";
|
|
21
23
|
import type { Express } from "express";
|
|
22
24
|
|
|
25
|
+
export { getRegisteredPushClients, type PushClientRecord };
|
|
26
|
+
|
|
23
27
|
export interface BroadcastPushOptions {
|
|
24
28
|
clientId?: string;
|
|
25
29
|
}
|
|
@@ -159,6 +163,12 @@ export function registerPushRoutes(app: Express): void {
|
|
|
159
163
|
res.json(subs);
|
|
160
164
|
});
|
|
161
165
|
|
|
166
|
+
// List unique registered clients with push subscriptions
|
|
167
|
+
app.get("/push/clients", (_req, res) => {
|
|
168
|
+
const clients = getRegisteredPushClients();
|
|
169
|
+
res.json({ clients });
|
|
170
|
+
});
|
|
171
|
+
|
|
162
172
|
// Send a notification to a specific subscription
|
|
163
173
|
app.post("/push/send", async (req, res) => {
|
|
164
174
|
const { endpoint, payload } = req.body || {};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
removeSubscriptionById,
|
|
10
10
|
getSubscriptionsByClientId,
|
|
11
11
|
findSubscriptionsForClient,
|
|
12
|
+
getRegisteredPushClients,
|
|
12
13
|
} from "./push-store.js";
|
|
13
14
|
|
|
14
15
|
// Use in-memory DB for tests
|
|
@@ -207,4 +208,52 @@ describe("push-store", () => {
|
|
|
207
208
|
expect(notFound).toHaveLength(0);
|
|
208
209
|
});
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
describe("getRegisteredPushClients", () => {
|
|
213
|
+
it("returns empty array when no subscriptions exist", () => {
|
|
214
|
+
expect(getRegisteredPushClients()).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns deduplicated registered push clients with device labels and counts", () => {
|
|
218
|
+
saveSubscription({
|
|
219
|
+
...MOCK_SUBSCRIPTION,
|
|
220
|
+
clientId: "client-01jtest123",
|
|
221
|
+
deviceLabel: "Pixel 9 Pro",
|
|
222
|
+
});
|
|
223
|
+
// Second subscription from same client (e.g. renewed endpoint)
|
|
224
|
+
saveSubscription({
|
|
225
|
+
endpoint: "https://fcm.googleapis.com/fcm/send/abc123-renewed",
|
|
226
|
+
keys: MOCK_SUBSCRIPTION.keys,
|
|
227
|
+
clientId: "client-01jtest123",
|
|
228
|
+
deviceLabel: "Pixel 9 Pro",
|
|
229
|
+
});
|
|
230
|
+
// Different client
|
|
231
|
+
saveSubscription({
|
|
232
|
+
...MOCK_SUBSCRIPTION_2,
|
|
233
|
+
clientId: "client-02ktest456",
|
|
234
|
+
deviceLabel: "MacBook Air",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const clients = getRegisteredPushClients();
|
|
238
|
+
expect(clients).toHaveLength(2);
|
|
239
|
+
|
|
240
|
+
const client1 = clients.find((c) => c.clientId === "client-01jtest123");
|
|
241
|
+
expect(client1).toBeDefined();
|
|
242
|
+
expect(client1?.deviceLabel).toBe("Pixel 9 Pro");
|
|
243
|
+
expect(client1?.subscriptionCount).toBe(2);
|
|
244
|
+
|
|
245
|
+
const client2 = clients.find((c) => c.clientId === "client-02ktest456");
|
|
246
|
+
expect(client2).toBeDefined();
|
|
247
|
+
expect(client2?.deviceLabel).toBe("MacBook Air");
|
|
248
|
+
expect(client2?.subscriptionCount).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("falls back to subscription id when clientId is omitted", () => {
|
|
252
|
+
saveSubscription(MOCK_SUBSCRIPTION);
|
|
253
|
+
const clients = getRegisteredPushClients();
|
|
254
|
+
expect(clients).toHaveLength(1);
|
|
255
|
+
const all = getAllSubscriptions();
|
|
256
|
+
expect(clients[0].clientId).toBe(String(all[0].id));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
210
259
|
});
|