@zhihand/mcp 0.30.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/zhihand +316 -129
- package/dist/core/command.js +4 -3
- package/dist/core/config.d.ts +35 -20
- package/dist/core/config.js +129 -55
- package/dist/core/device.d.ts +3 -3
- package/dist/core/device.js +22 -14
- package/dist/core/logger.d.ts +17 -0
- package/dist/core/logger.js +32 -0
- package/dist/core/pair.d.ts +39 -31
- package/dist/core/pair.js +188 -84
- package/dist/core/registry.d.ts +23 -30
- package/dist/core/registry.js +321 -194
- package/dist/core/screenshot.js +3 -2
- package/dist/core/sse.d.ts +32 -7
- package/dist/core/sse.js +90 -22
- package/dist/core/ws.d.ts +92 -0
- package/dist/core/ws.js +327 -0
- package/dist/daemon/dispatcher.js +1 -1
- package/dist/daemon/heartbeat.js +1 -1
- package/dist/daemon/index.js +4 -4
- package/dist/daemon/prompt-listener.d.ts +5 -6
- package/dist/daemon/prompt-listener.js +58 -94
- package/dist/index.d.ts +1 -1
- package/dist/index.js +18 -16
- package/dist/tools/control.js +1 -1
- package/dist/tools/pair.d.ts +1 -1
- package/dist/tools/pair.js +22 -25
- package/dist/tools/system.js +1 -1
- package/package.json +3 -1
- package/README.md +0 -359
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
import { ReconnectingWebSocket } from "../core/ws.js";
|
|
1
2
|
import { dbg } from "./logger.js";
|
|
2
|
-
const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
|
|
3
|
-
const SSE_RECONNECT_DELAY = 3_000;
|
|
4
3
|
const POLL_INTERVAL = 2_000;
|
|
5
4
|
export class PromptListener {
|
|
6
5
|
config;
|
|
7
6
|
handler;
|
|
8
7
|
log;
|
|
9
8
|
processedIds = new Set();
|
|
10
|
-
|
|
9
|
+
rws = null;
|
|
11
10
|
pollTimer = null;
|
|
12
|
-
|
|
11
|
+
wsConnected = false;
|
|
13
12
|
stopped = false;
|
|
14
13
|
constructor(config, handler, log) {
|
|
15
14
|
this.config = config;
|
|
@@ -18,16 +17,14 @@ export class PromptListener {
|
|
|
18
17
|
}
|
|
19
18
|
start() {
|
|
20
19
|
this.stopped = false;
|
|
21
|
-
this.
|
|
20
|
+
this.connectWS();
|
|
22
21
|
}
|
|
23
22
|
stop() {
|
|
24
23
|
this.stopped = true;
|
|
25
|
-
this.
|
|
26
|
-
this.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
this.pollTimer = null;
|
|
30
|
-
}
|
|
24
|
+
this.rws?.stop();
|
|
25
|
+
this.rws = null;
|
|
26
|
+
this.wsConnected = false;
|
|
27
|
+
this.stopPolling();
|
|
31
28
|
}
|
|
32
29
|
dispatchPrompt(prompt) {
|
|
33
30
|
if (this.processedIds.has(prompt.id)) {
|
|
@@ -43,113 +40,78 @@ export class PromptListener {
|
|
|
43
40
|
}
|
|
44
41
|
this.handler(prompt);
|
|
45
42
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
});
|
|
59
|
-
if (!response.ok) {
|
|
60
|
-
dbg(`[sse] Connect failed: ${response.status} ${response.statusText}`);
|
|
61
|
-
throw new Error(`SSE connect failed: ${response.status}`);
|
|
62
|
-
}
|
|
63
|
-
this.sseConnected = true;
|
|
43
|
+
connectWS() {
|
|
44
|
+
if (this.stopped)
|
|
45
|
+
return;
|
|
46
|
+
const wsUrl = `${this.config.controlPlaneEndpoint.replace(/^http/, "ws")}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/ws?topic=prompts`;
|
|
47
|
+
dbg(`[ws] Connecting to ${wsUrl}`);
|
|
48
|
+
this.rws = new ReconnectingWebSocket({
|
|
49
|
+
url: wsUrl,
|
|
50
|
+
headers: {
|
|
51
|
+
"Authorization": `Bearer ${this.config.controllerToken}`,
|
|
52
|
+
},
|
|
53
|
+
onOpen: () => {
|
|
54
|
+
this.wsConnected = true;
|
|
64
55
|
this.stopPolling();
|
|
65
|
-
this.log("[
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
while (!this.stopped) {
|
|
74
|
-
const { done, value } = await reader.read();
|
|
75
|
-
if (done)
|
|
76
|
-
break;
|
|
77
|
-
// Reset watchdog on any data (including keepalive comments)
|
|
78
|
-
clearTimeout(watchdog);
|
|
79
|
-
watchdog = this.resetWatchdog();
|
|
80
|
-
buffer += decoder.decode(value, { stream: true });
|
|
81
|
-
const lines = buffer.split("\n");
|
|
82
|
-
buffer = lines.pop() ?? "";
|
|
83
|
-
let eventData = "";
|
|
84
|
-
for (const line of lines) {
|
|
85
|
-
if (line.startsWith("data: ")) {
|
|
86
|
-
eventData += (eventData ? "\n" : "") + line.slice(6);
|
|
87
|
-
}
|
|
88
|
-
else if (line === "" && eventData) {
|
|
89
|
-
try {
|
|
90
|
-
const event = JSON.parse(eventData);
|
|
91
|
-
this.handleSSEEvent(event);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
// Malformed event
|
|
95
|
-
}
|
|
96
|
-
eventData = "";
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
// Always clear watchdog — prevents leaked timer from aborting next connection
|
|
103
|
-
clearTimeout(watchdog);
|
|
56
|
+
this.log("[ws] Connected to prompt stream.");
|
|
57
|
+
},
|
|
58
|
+
onClose: (_code, _reason) => {
|
|
59
|
+
if (this.wsConnected) {
|
|
60
|
+
this.wsConnected = false;
|
|
61
|
+
this.log("[ws] Disconnected. Falling back to polling.");
|
|
62
|
+
this.startPolling();
|
|
104
63
|
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
64
|
+
},
|
|
65
|
+
onMessage: (data) => {
|
|
66
|
+
this.handleWSMessage(data);
|
|
67
|
+
},
|
|
68
|
+
onError: (err) => {
|
|
69
|
+
dbg(`[ws] Error: ${err.message}`);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
this.rws.start();
|
|
115
73
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
74
|
+
handleWSMessage(data) {
|
|
75
|
+
const msg = data;
|
|
76
|
+
// Application-level ping (if server sends these alongside protocol pings)
|
|
77
|
+
if (msg.type === "ping") {
|
|
78
|
+
this.rws?.send(JSON.stringify({ type: "pong" }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Event dispatch
|
|
82
|
+
if (msg.type === "event" || msg.kind) {
|
|
83
|
+
this.handleEvent(msg);
|
|
84
|
+
}
|
|
121
85
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
86
|
+
handleEvent(event) {
|
|
87
|
+
const kind = event.kind;
|
|
88
|
+
dbg(`[ws] Event: kind=${kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
|
|
89
|
+
if (kind === "prompt.queued" && event.prompt) {
|
|
125
90
|
this.dispatchPrompt(event.prompt);
|
|
126
91
|
}
|
|
127
|
-
else if (
|
|
92
|
+
else if (kind === "prompt.snapshot" && event.prompts) {
|
|
128
93
|
for (const p of event.prompts) {
|
|
129
94
|
if (p.status === "pending" || p.status === "processing") {
|
|
130
95
|
this.dispatchPrompt(p);
|
|
131
96
|
}
|
|
132
97
|
}
|
|
133
98
|
}
|
|
134
|
-
else if (
|
|
135
|
-
// Registry owns device-profile updates; this listener is only for prompts.
|
|
99
|
+
else if (kind === "device_profile.updated") {
|
|
136
100
|
this.log("[device] device_profile.updated event received on prompts stream (ignored; registry handles it)");
|
|
137
101
|
}
|
|
138
102
|
}
|
|
139
103
|
startPolling() {
|
|
140
|
-
if (this.pollTimer)
|
|
104
|
+
if (this.pollTimer || this.stopped)
|
|
141
105
|
return;
|
|
142
106
|
this.schedulePoll();
|
|
143
107
|
}
|
|
144
|
-
/** Recursive setTimeout: waits for fetch to complete before scheduling next poll. */
|
|
145
108
|
schedulePoll() {
|
|
146
109
|
if (this.pollTimer)
|
|
147
110
|
return;
|
|
148
111
|
this.pollTimer = setTimeout(async () => {
|
|
149
112
|
this.pollTimer = null;
|
|
150
113
|
await this.poll();
|
|
151
|
-
|
|
152
|
-
if (!this.sseConnected && !this.stopped) {
|
|
114
|
+
if (!this.wsConnected && !this.stopped) {
|
|
153
115
|
this.schedulePoll();
|
|
154
116
|
}
|
|
155
117
|
}, POLL_INTERVAL);
|
|
@@ -165,7 +127,7 @@ export class PromptListener {
|
|
|
165
127
|
const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
|
|
166
128
|
dbg(`[poll] GET ${url}`);
|
|
167
129
|
const response = await fetch(url, {
|
|
168
|
-
headers: { "
|
|
130
|
+
headers: { "Authorization": `Bearer ${this.config.controllerToken}` },
|
|
169
131
|
signal: AbortSignal.timeout(10_000),
|
|
170
132
|
});
|
|
171
133
|
if (!response.ok) {
|
|
@@ -174,6 +136,8 @@ export class PromptListener {
|
|
|
174
136
|
}
|
|
175
137
|
const data = (await response.json());
|
|
176
138
|
dbg(`[poll] Got ${data.items?.length ?? 0} prompt(s)`);
|
|
139
|
+
if (this.stopped)
|
|
140
|
+
return; // Guard against late responses after stop()
|
|
177
141
|
for (const prompt of data.items ?? []) {
|
|
178
142
|
this.dispatchPrompt(prompt);
|
|
179
143
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
export declare const PACKAGE_VERSION = "0.
|
|
2
|
+
export declare const PACKAGE_VERSION = "0.31.0";
|
|
3
3
|
export declare function createServer(): McpServer;
|
|
4
4
|
export declare function startStdioServer(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,9 @@ import { executeSystem } from "./tools/system.js";
|
|
|
6
6
|
import { handleScreenshot } from "./tools/screenshot.js";
|
|
7
7
|
import { handlePair } from "./tools/pair.js";
|
|
8
8
|
import { resolveTargetDevice } from "./tools/resolve.js";
|
|
9
|
-
import { buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
|
|
9
|
+
import { buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, extractDynamic, } from "./core/device.js";
|
|
10
10
|
import { registry } from "./core/registry.js";
|
|
11
|
-
export const PACKAGE_VERSION = "0.
|
|
11
|
+
export const PACKAGE_VERSION = "0.31.0";
|
|
12
12
|
function errorResult(message) {
|
|
13
13
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
14
14
|
}
|
|
@@ -17,7 +17,8 @@ export function createServer() {
|
|
|
17
17
|
name: "zhihand",
|
|
18
18
|
version: PACKAGE_VERSION,
|
|
19
19
|
});
|
|
20
|
-
const
|
|
20
|
+
const multiUser = registry.isMultiUser();
|
|
21
|
+
const controlTool = server.tool("zhihand_control", buildControlToolDescription(null, registry.listOnline(), multiUser), controlSchema, async (params) => {
|
|
21
22
|
const resolved = resolveTargetDevice(params.device_id);
|
|
22
23
|
if ("error" in resolved)
|
|
23
24
|
return errorResult(resolved.error);
|
|
@@ -26,7 +27,7 @@ export function createServer() {
|
|
|
26
27
|
const platform = state.profile?.platform ?? "unknown";
|
|
27
28
|
return await executeControl(cfg, params, platform, state.capabilities);
|
|
28
29
|
});
|
|
29
|
-
const systemTool = server.tool("zhihand_system", buildSystemToolDescription(null, registry.listOnline()), systemSchema, async (params) => {
|
|
30
|
+
const systemTool = server.tool("zhihand_system", buildSystemToolDescription(null, registry.listOnline(), multiUser), systemSchema, async (params) => {
|
|
30
31
|
const resolved = resolveTargetDevice(params.device_id);
|
|
31
32
|
if ("error" in resolved)
|
|
32
33
|
return errorResult(resolved.error);
|
|
@@ -35,7 +36,7 @@ export function createServer() {
|
|
|
35
36
|
const platform = state.profile?.platform ?? "unknown";
|
|
36
37
|
return await executeSystem(cfg, params, platform);
|
|
37
38
|
});
|
|
38
|
-
const screenshotTool = server.tool("zhihand_screenshot", buildScreenshotToolDescription(null, registry.listOnline()), screenshotSchema, async (params) => {
|
|
39
|
+
const screenshotTool = server.tool("zhihand_screenshot", buildScreenshotToolDescription(null, registry.listOnline(), multiUser), screenshotSchema, async (params) => {
|
|
39
40
|
const resolved = resolveTargetDevice(params.device_id);
|
|
40
41
|
if ("error" in resolved)
|
|
41
42
|
return errorResult(resolved.error);
|
|
@@ -54,14 +55,17 @@ export function createServer() {
|
|
|
54
55
|
}],
|
|
55
56
|
};
|
|
56
57
|
});
|
|
57
|
-
server.tool("zhihand_list_devices", "List ALL configured ZhiHand devices with their online status. Returns
|
|
58
|
-
const
|
|
58
|
+
server.tool("zhihand_list_devices", "List ALL configured ZhiHand devices with their online status. Returns device_id, label, platform, online, battery, is_default, last_active for each. Call this before zhihand_control/system/screenshot/status when multiple devices may be online.", listDevicesSchema, async () => {
|
|
59
|
+
const mu = registry.isMultiUser();
|
|
60
|
+
const defaultDev = registry.resolveDefault();
|
|
59
61
|
const devices = registry.list().map((d) => ({
|
|
60
|
-
|
|
61
|
-
label: d.label,
|
|
62
|
+
device_id: d.credentialId,
|
|
63
|
+
label: mu ? `[${d.userLabel}] ${d.label}` : d.label,
|
|
62
64
|
platform: d.platform,
|
|
63
65
|
online: d.online,
|
|
64
|
-
|
|
66
|
+
battery: d.rawAttributes ? extractDynamic(d.rawAttributes).batteryLevel : null,
|
|
67
|
+
is_default: d === defaultDev,
|
|
68
|
+
last_active: d.lastSeenAtMs > 0 ? new Date(d.lastSeenAtMs).toISOString() : null,
|
|
65
69
|
}));
|
|
66
70
|
return {
|
|
67
71
|
content: [{
|
|
@@ -74,21 +78,19 @@ export function createServer() {
|
|
|
74
78
|
return await handlePair(params);
|
|
75
79
|
});
|
|
76
80
|
// Dynamic tool-description updates on online-set change.
|
|
77
|
-
// Each .update() call also triggers sendToolListChanged() internally, but we
|
|
78
|
-
// wrap each individually so one throwing does NOT block the others, and we
|
|
79
|
-
// still call sendToolListChanged() explicitly as a belt-and-braces signal.
|
|
80
81
|
registry.subscribe(() => {
|
|
81
82
|
const online = registry.listOnline();
|
|
83
|
+
const mu = registry.isMultiUser();
|
|
82
84
|
try {
|
|
83
|
-
controlTool.update({ description: buildControlToolDescription(null, online) });
|
|
85
|
+
controlTool.update({ description: buildControlToolDescription(null, online, mu) });
|
|
84
86
|
}
|
|
85
87
|
catch { /* best-effort */ }
|
|
86
88
|
try {
|
|
87
|
-
systemTool.update({ description: buildSystemToolDescription(null, online) });
|
|
89
|
+
systemTool.update({ description: buildSystemToolDescription(null, online, mu) });
|
|
88
90
|
}
|
|
89
91
|
catch { /* best-effort */ }
|
|
90
92
|
try {
|
|
91
|
-
screenshotTool.update({ description: buildScreenshotToolDescription(null, online) });
|
|
93
|
+
screenshotTool.update({ description: buildScreenshotToolDescription(null, online, mu) });
|
|
92
94
|
}
|
|
93
95
|
catch { /* best-effort */ }
|
|
94
96
|
try {
|
package/dist/tools/control.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createControlCommand, enqueueCommand, formatAckSummary } from "../core/command.js";
|
|
2
2
|
import { fetchScreenshot } from "../core/screenshot.js";
|
|
3
|
-
import { waitForCommandAck } from "../core/
|
|
3
|
+
import { waitForCommandAck } from "../core/ws.js";
|
|
4
4
|
function sleep(ms) {
|
|
5
5
|
return new Promise((r) => setTimeout(r, ms));
|
|
6
6
|
}
|
package/dist/tools/pair.d.ts
CHANGED
package/dist/tools/pair.js
CHANGED
|
@@ -1,29 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createPairingSession, registerPlugin, renderPairingQRCode, } from "../core/pair.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
...records.map((r) => ` - ${r.credential_id} (${r.label}, ${r.platform}) via ${r.endpoint}`),
|
|
18
|
-
"",
|
|
19
|
-
"Pass forceNew=true to pair another device.",
|
|
20
|
-
];
|
|
21
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
22
|
-
}
|
|
1
|
+
import { listUsers } from "../core/config.js";
|
|
2
|
+
import { createPairingSession, registerPlugin, renderPairingQRCode, createUser, } from "../core/pair.js";
|
|
3
|
+
import { resolveDefaultEndpoint } from "../core/config.js";
|
|
4
|
+
export async function handlePair(params) {
|
|
5
|
+
const endpoint = resolveDefaultEndpoint();
|
|
6
|
+
const users = listUsers();
|
|
7
|
+
if (!params.forceNew && users.length > 0) {
|
|
8
|
+
const lines = [
|
|
9
|
+
"Already paired with:",
|
|
10
|
+
"",
|
|
11
|
+
...users.map((u) => ` User: ${u.label} (${u.user_id})\n` +
|
|
12
|
+
u.devices.map((d) => ` - ${d.credential_id} (${d.label}, ${d.platform})`).join("\n")),
|
|
13
|
+
"",
|
|
14
|
+
"Pass forceNew=true to pair another device.",
|
|
15
|
+
];
|
|
16
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
23
17
|
}
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
18
|
+
// Create a new user for MCP-tool pairing
|
|
19
|
+
const label = `MCP-${Date.now().toString(36)}`;
|
|
20
|
+
const userResp = await createUser(endpoint, label);
|
|
21
|
+
const stableIdentity = `mcp-${Date.now().toString(36)}`;
|
|
22
|
+
const plugin = await registerPlugin(endpoint, { stableIdentity });
|
|
23
|
+
const session = await createPairingSession(endpoint, userResp.user_id, userResp.controller_token, plugin.edge_id, 300);
|
|
27
24
|
const qr = await renderPairingQRCode(session.pair_url);
|
|
28
25
|
return {
|
|
29
26
|
content: [
|
package/dist/tools/system.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSystemCommand, enqueueCommand, formatAckSummary } from "../core/command.js";
|
|
2
|
-
import { waitForCommandAck } from "../core/
|
|
2
|
+
import { waitForCommandAck } from "../core/ws.js";
|
|
3
3
|
const IOS_ONLY = new Set(["siri", "control_center"]);
|
|
4
4
|
const ANDROID_ONLY = new Set(["open_browser", "shortcut_help"]);
|
|
5
5
|
export async function executeSystem(config, params, platform) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhihand/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ZhiHand MCP Server — phone control tools for Claude Code, Codex, Gemini CLI, and OpenClaw",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
40
40
|
"qrcode": "^1.5.4",
|
|
41
|
+
"ws": "^8.20.0",
|
|
41
42
|
"zod": "^3.24.0"
|
|
42
43
|
},
|
|
43
44
|
"engines": {
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^25.5.0",
|
|
48
49
|
"@types/qrcode": "^1.5.6",
|
|
50
|
+
"@types/ws": "^8.18.1",
|
|
49
51
|
"typescript": "^6.0.2"
|
|
50
52
|
}
|
|
51
53
|
}
|