@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.
@@ -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
- sseAbort = null;
9
+ rws = null;
11
10
  pollTimer = null;
12
- sseConnected = false;
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.connectSSE();
20
+ this.connectWS();
22
21
  }
23
22
  stop() {
24
23
  this.stopped = true;
25
- this.sseAbort?.abort();
26
- this.sseAbort = null;
27
- if (this.pollTimer) {
28
- clearTimeout(this.pollTimer);
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
- async connectSSE() {
47
- while (!this.stopped) {
48
- try {
49
- this.sseAbort = new AbortController();
50
- const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/events/stream?topic=prompts`;
51
- dbg(`[sse] Connecting to ${url}`);
52
- const response = await fetch(url, {
53
- headers: {
54
- "Accept": "text/event-stream",
55
- "x-zhihand-controller-token": this.config.controllerToken,
56
- },
57
- signal: this.sseAbort.signal,
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("[sse] Connected to prompt stream.");
66
- const reader = response.body?.getReader();
67
- if (!reader)
68
- throw new Error("No response body for SSE");
69
- const decoder = new TextDecoder();
70
- let buffer = "";
71
- let watchdog = this.resetWatchdog();
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
- catch (err) {
107
- if (this.stopped)
108
- break;
109
- this.sseConnected = false;
110
- this.log(`[sse] Disconnected. Falling back to polling. (${err.message})`);
111
- this.startPolling();
112
- await new Promise((r) => setTimeout(r, SSE_RECONNECT_DELAY));
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
- resetWatchdog() {
117
- return setTimeout(() => {
118
- this.log("[sse] Watchdog timeout (120s no data). Reconnecting...");
119
- this.sseAbort?.abort();
120
- }, SSE_WATCHDOG_TIMEOUT);
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
- handleSSEEvent(event) {
123
- dbg(`[sse] Event: kind=${event.kind}, prompt=${event.prompt?.id ?? "-"}, prompts=${event.prompts?.length ?? 0}`);
124
- if (event.kind === "prompt.queued" && event.prompt) {
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 (event.kind === "prompt.snapshot" && event.prompts) {
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 (event.kind === "device_profile.updated" && event.device_profile) {
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
- // Schedule next poll only if SSE is still disconnected
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: { "x-zhihand-controller-token": this.config.controllerToken },
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.30.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.30.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 controlTool = server.tool("zhihand_control", buildControlToolDescription(null, registry.listOnline()), controlSchema, async (params) => {
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 credential_id, label, platform, online, last_seen_ms_ago for each. Call this before zhihand_control/system/screenshot/status when multiple devices may be online.", listDevicesSchema, async () => {
58
- const now = Date.now();
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
- credential_id: d.credentialId,
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
- last_seen_ms_ago: d.lastSeenAtMs > 0 ? now - d.lastSeenAtMs : -1,
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 {
@@ -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/sse.js";
3
+ import { waitForCommandAck } from "../core/ws.js";
4
4
  function sleep(ms) {
5
5
  return new Promise((r) => setTimeout(r, ms));
6
6
  }
@@ -1,6 +1,6 @@
1
1
  export declare function handlePair(params: {
2
2
  forceNew?: boolean;
3
- }, endpoint?: string): Promise<{
3
+ }): Promise<{
4
4
  content: {
5
5
  type: "text";
6
6
  text: string;
@@ -1,29 +1,26 @@
1
- import { loadConfig } from "../core/config.js";
2
- import { createPairingSession, registerPlugin, renderPairingQRCode, } from "../core/pair.js";
3
- const DEFAULT_ENDPOINT = "https://api.zhihand.com";
4
- const DEFAULT_EDGE_ID_PREFIX = "mcp-";
5
- function generateEdgeId() {
6
- return `${DEFAULT_EDGE_ID_PREFIX}${Date.now().toString(36)}`;
7
- }
8
- export async function handlePair(params, endpoint) {
9
- const resolvedEndpoint = endpoint ?? DEFAULT_ENDPOINT;
10
- if (!params.forceNew) {
11
- const cfg = loadConfig();
12
- const records = Object.values(cfg.devices);
13
- if (records.length > 0) {
14
- const lines = [
15
- "Already paired with:",
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
- const stableIdentity = generateEdgeId();
25
- const plugin = await registerPlugin(resolvedEndpoint, { stableIdentity });
26
- const session = await createPairingSession(resolvedEndpoint, { edgeId: plugin.edge_id });
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: [
@@ -1,5 +1,5 @@
1
1
  import { createSystemCommand, enqueueCommand, formatAckSummary } from "../core/command.js";
2
- import { waitForCommandAck } from "../core/sse.js";
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.30.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
  }