@zhihand/mcp 0.29.0 → 0.32.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.
Files changed (42) hide show
  1. package/bin/zhihand +448 -212
  2. package/dist/core/command.d.ts +5 -5
  3. package/dist/core/command.js +6 -8
  4. package/dist/core/config.d.ts +48 -21
  5. package/dist/core/config.js +178 -42
  6. package/dist/core/device.d.ts +28 -19
  7. package/dist/core/device.js +168 -145
  8. package/dist/core/logger.d.ts +17 -0
  9. package/dist/core/logger.js +32 -0
  10. package/dist/core/pair.d.ts +39 -31
  11. package/dist/core/pair.js +205 -77
  12. package/dist/core/registry.d.ts +60 -0
  13. package/dist/core/registry.js +415 -0
  14. package/dist/core/screenshot.d.ts +3 -3
  15. package/dist/core/screenshot.js +3 -2
  16. package/dist/core/sse.d.ts +40 -18
  17. package/dist/core/sse.js +122 -62
  18. package/dist/core/ws.d.ts +92 -0
  19. package/dist/core/ws.js +327 -0
  20. package/dist/daemon/dispatcher.d.ts +3 -1
  21. package/dist/daemon/dispatcher.js +4 -3
  22. package/dist/daemon/heartbeat.d.ts +4 -4
  23. package/dist/daemon/heartbeat.js +1 -1
  24. package/dist/daemon/index.js +10 -8
  25. package/dist/daemon/prompt-listener.d.ts +8 -7
  26. package/dist/daemon/prompt-listener.js +59 -99
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +104 -40
  29. package/dist/openclaw.adapter.js +10 -2
  30. package/dist/tools/control.d.ts +10 -3
  31. package/dist/tools/control.js +18 -24
  32. package/dist/tools/pair.d.ts +1 -1
  33. package/dist/tools/pair.js +22 -28
  34. package/dist/tools/resolve.d.ts +7 -0
  35. package/dist/tools/resolve.js +22 -0
  36. package/dist/tools/schemas.d.ts +9 -1
  37. package/dist/tools/schemas.js +10 -8
  38. package/dist/tools/screenshot.d.ts +3 -2
  39. package/dist/tools/screenshot.js +2 -2
  40. package/dist/tools/system.d.ts +3 -5
  41. package/dist/tools/system.js +19 -6
  42. package/package.json +3 -1
@@ -1,40 +1,36 @@
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";
4
- import { getCapabilities, isDeviceProfileLoaded } from "../core/device.js";
3
+ import { waitForCommandAck } from "../core/ws.js";
5
4
  function sleep(ms) {
6
5
  return new Promise((r) => setTimeout(r, ms));
7
6
  }
8
7
  /**
9
8
  * Build a short human-readable warning for the LLM if the underlying
10
- * capability isn't ready, or if the last screenshot is stale. Returns
11
- * empty string when everything is nominal.
9
+ * capability isn't ready, or if the last screenshot is stale.
12
10
  */
13
- function buildReadinessWarning(requiredCapability, screenshot) {
14
- if (!isDeviceProfileLoaded())
11
+ export function buildReadinessWarning(requiredCapability, capabilities, screenshot) {
12
+ if (!capabilities)
15
13
  return "";
16
- const caps = getCapabilities();
17
14
  const warnings = [];
18
- if (requiredCapability === "hid" && !caps.hid.ready) {
19
- warnings.push(`⚠️ HID not ready: ${caps.hid.reason}`);
15
+ if (requiredCapability === "hid" && !capabilities.hid.ready) {
16
+ warnings.push(`⚠️ HID not ready: ${capabilities.hid.reason}`);
20
17
  }
21
- if (requiredCapability === "screen" && !caps.screen_sharing.ready) {
22
- warnings.push(`⚠️ Screen sharing not active: ${caps.screen_sharing.reason}`);
18
+ if (requiredCapability === "screen" && !capabilities.screen_sharing.ready) {
19
+ warnings.push(`⚠️ Screen sharing not active: ${capabilities.screen_sharing.reason}`);
23
20
  }
24
21
  if (screenshot && screenshot.stale) {
25
22
  warnings.push(`⚠️ Stale screenshot: age=${(screenshot.ageMs / 1000).toFixed(1)}s (phone may not be actively sharing the screen).`);
26
23
  }
27
- if (caps.profile.stale) {
28
- warnings.push(`⚠️ Stale device profile: ${(caps.profile.age_ms / 1000).toFixed(1)}s old — readiness flags may be out of date.`);
24
+ if (capabilities.profile.stale) {
25
+ warnings.push(`⚠️ Stale device profile: ${(capabilities.profile.age_ms / 1000).toFixed(1)}s old — readiness flags may be out of date.`);
29
26
  }
30
27
  return warnings.join("\n");
31
28
  }
32
- export async function executeControl(config, params) {
33
- // wait: Plugin-local implementation, no server round-trip
29
+ export async function executeControl(config, params, platform, capabilities) {
34
30
  if (params.action === "wait") {
35
31
  await sleep(params.durationMs ?? 1000);
36
32
  const shot = await fetchScreenshot(config);
37
- const warning = buildReadinessWarning("screen", shot);
33
+ const warning = buildReadinessWarning("screen", capabilities, shot);
38
34
  const content = [];
39
35
  if (warning)
40
36
  content.push({ type: "text", text: warning });
@@ -42,12 +38,10 @@ export async function executeControl(config, params) {
42
38
  content.push({ type: "image", data: shot.buffer.toString("base64"), mimeType: "image/jpeg" });
43
39
  return { content };
44
40
  }
45
- // screenshot: send receive_screenshot, App captures immediately (no 2s delay)
46
41
  if (params.action === "screenshot") {
47
- return await executeScreenshot(config);
42
+ return await executeScreenshot(config, capabilities);
48
43
  }
49
- // HID operations: enqueue → ACK → GET screenshot
50
- const command = createControlCommand(params);
44
+ const command = createControlCommand(params, platform);
51
45
  const queued = await enqueueCommand(config, command);
52
46
  const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 15_000 });
53
47
  const content = [];
@@ -57,10 +51,10 @@ export async function executeControl(config, params) {
57
51
  shot = await fetchScreenshot(config);
58
52
  }
59
53
  catch {
60
- // Screenshot is best-effort after ACK
54
+ // best-effort
61
55
  }
62
56
  }
63
- const warning = buildReadinessWarning("hid", shot);
57
+ const warning = buildReadinessWarning("hid", capabilities, shot);
64
58
  if (warning)
65
59
  content.push({ type: "text", text: warning });
66
60
  content.push({ type: "text", text: formatAckSummary(params.action, ack) });
@@ -69,12 +63,12 @@ export async function executeControl(config, params) {
69
63
  }
70
64
  return { content };
71
65
  }
72
- export async function executeScreenshot(config) {
66
+ export async function executeScreenshot(config, capabilities) {
73
67
  const command = createControlCommand({ action: "screenshot" });
74
68
  const queued = await enqueueCommand(config, command);
75
69
  const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 5_000 });
76
70
  const shot = await fetchScreenshot(config);
77
- const warning = buildReadinessWarning("screen", shot);
71
+ const warning = buildReadinessWarning("screen", capabilities, shot);
78
72
  const content = [];
79
73
  if (warning)
80
74
  content.push({ type: "text", text: warning });
@@ -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,32 +1,26 @@
1
- import { loadDefaultCredential } from "../core/config.js";
2
- import { createPairingSession, registerPlugin, renderPairingQRCode, formatPairingStatus, } 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
- // Check existing credential
11
- if (!params.forceNew) {
12
- const existing = loadDefaultCredential();
13
- if (existing) {
14
- return {
15
- content: [
16
- { type: "text", text: formatPairingStatus(existing) },
17
- ],
18
- };
19
- }
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") }] };
20
17
  }
21
- // Register plugin first server requires a known edge_id before pairing
22
- const stableIdentity = generateEdgeId();
23
- const plugin = await registerPlugin(resolvedEndpoint, {
24
- stableIdentity,
25
- });
26
- // Create new pairing session with the registered edge_id
27
- const session = await createPairingSession(resolvedEndpoint, {
28
- edgeId: plugin.edge_id,
29
- });
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);
30
24
  const qr = await renderPairingQRCode(session.pair_url);
31
25
  return {
32
26
  content: [
@@ -0,0 +1,7 @@
1
+ import { type DeviceState } from "../core/registry.ts";
2
+ export type ResolveResult = {
3
+ state: DeviceState;
4
+ } | {
5
+ error: string;
6
+ };
7
+ export declare function resolveTargetDevice(deviceId: string | undefined): ResolveResult;
@@ -0,0 +1,22 @@
1
+ import { registry } from "../core/registry.js";
2
+ export function resolveTargetDevice(deviceId) {
3
+ const online = registry.listOnline();
4
+ if (deviceId) {
5
+ const s = registry.get(deviceId);
6
+ if (!s) {
7
+ return { error: `Device '${deviceId}' not found. Call zhihand_list_devices.` };
8
+ }
9
+ if (!s.online) {
10
+ return { error: `Device '${deviceId}' is offline. Call zhihand_list_devices for online devices.` };
11
+ }
12
+ return { state: s };
13
+ }
14
+ if (online.length === 0) {
15
+ return { error: "No devices online. Ask user to open the ZhiHand app." };
16
+ }
17
+ if (online.length === 1) {
18
+ return { state: online[0] };
19
+ }
20
+ const ids = online.map((d) => `${d.credentialId} (${d.label})`).join(", ");
21
+ return { error: `Multiple devices online — pass device_id. Online: ${ids}` };
22
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const controlSchema: {
3
+ device_id: z.ZodOptional<z.ZodString>;
3
4
  action: z.ZodEnum<["click", "doubleclick", "longclick", "rightclick", "middleclick", "type", "swipe", "scroll", "keycombo", "back", "home", "enter", "clipboard", "open_app", "wait", "screenshot"]>;
4
5
  xRatio: z.ZodOptional<z.ZodNumber>;
5
6
  yRatio: z.ZodOptional<z.ZodNumber>;
@@ -17,10 +18,17 @@ export declare const controlSchema: {
17
18
  urlScheme: z.ZodOptional<z.ZodString>;
18
19
  };
19
20
  export declare const systemSchema: {
21
+ device_id: z.ZodOptional<z.ZodString>;
20
22
  action: z.ZodEnum<["notification", "recent", "search", "switch_input", "siri", "control_center", "open_browser", "shortcut_help", "volume_up", "volume_down", "mute", "play_pause", "stop", "next_track", "prev_track", "fast_forward", "rewind", "brightness_up", "brightness_down", "power"]>;
21
23
  text: z.ZodOptional<z.ZodString>;
22
24
  };
23
- export declare const screenshotSchema: {};
25
+ export declare const screenshotSchema: {
26
+ device_id: z.ZodOptional<z.ZodString>;
27
+ };
24
28
  export declare const pairSchema: {
25
29
  forceNew: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
26
30
  };
31
+ export declare const listDevicesSchema: {};
32
+ export declare const statusSchema: {
33
+ device_id: z.ZodOptional<z.ZodString>;
34
+ };
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
+ const deviceIdSchema = z.string().optional().describe("Credential ID of the target device. Optional if only one device is online. Call zhihand_list_devices to see online devices.");
2
3
  export const controlSchema = {
4
+ device_id: deviceIdSchema,
3
5
  action: z.enum([
4
6
  "click", "doubleclick", "longclick", "rightclick", "middleclick",
5
7
  "type", "swipe", "scroll", "keycombo",
@@ -13,7 +15,6 @@ export const controlSchema = {
13
15
  direction: z.enum(["up", "down", "left", "right"]).optional().describe("Scroll direction"),
14
16
  amount: z.number().int().positive().default(3).optional().describe("Scroll steps (default 3)"),
15
17
  keys: z.string().optional().describe("Key combo string, e.g. 'ctrl+c', 'alt+tab'"),
16
- // clipboardAction removed — app only supports set (text via "text" param)
17
18
  durationMs: z.number().int().positive().max(10000).optional().describe("Duration in ms: wait (default 1000), longclick (default 800), swipe (default 300). Max 10000"),
18
19
  startXRatio: z.number().min(0).max(1).optional().describe("Swipe start X [0,1]"),
19
20
  startYRatio: z.number().min(0).max(1).optional().describe("Swipe start Y [0,1]"),
@@ -23,25 +24,26 @@ export const controlSchema = {
23
24
  bundleId: z.string().optional().describe("iOS bundle ID, e.g. 'com.tencent.xin'"),
24
25
  urlScheme: z.string().optional().describe("URL scheme, e.g. 'weixin://'"),
25
26
  };
26
- // zhihand_system — system navigation + media controls (separate from UI control)
27
27
  export const systemSchema = {
28
+ device_id: deviceIdSchema,
28
29
  action: z.enum([
29
- // System navigation — cross-platform
30
30
  "notification", "recent", "search", "switch_input",
31
- // System navigation — iOS only
32
31
  "siri", "control_center",
33
- // System navigation — Android only
34
32
  "open_browser", "shortcut_help",
35
- // Media controls — cross-platform
36
33
  "volume_up", "volume_down", "mute",
37
34
  "play_pause", "stop", "next_track", "prev_track",
38
35
  "fast_forward", "rewind",
39
- // Hardware — cross-platform
40
36
  "brightness_up", "brightness_down", "power",
41
37
  ]).describe("System or media action to perform"),
42
38
  text: z.string().optional().describe("Optional text, e.g. search query for 'search' action"),
43
39
  };
44
- export const screenshotSchema = {};
40
+ export const screenshotSchema = {
41
+ device_id: deviceIdSchema,
42
+ };
45
43
  export const pairSchema = {
46
44
  forceNew: z.boolean().default(false).optional().describe("Force new pairing even if already paired"),
47
45
  };
46
+ export const listDevicesSchema = {};
47
+ export const statusSchema = {
48
+ device_id: deviceIdSchema,
49
+ };
@@ -1,5 +1,6 @@
1
- import type { ZhiHandConfig } from "../core/config.ts";
2
- export declare function handleScreenshot(config: ZhiHandConfig): Promise<{
1
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
2
+ import type { Capabilities } from "../core/device.ts";
3
+ export declare function handleScreenshot(config: ZhiHandRuntimeConfig, capabilities: Capabilities | null): Promise<{
3
4
  content: ({
4
5
  type: "text";
5
6
  text: string;
@@ -1,4 +1,4 @@
1
1
  import { executeScreenshot } from "./control.js";
2
- export async function handleScreenshot(config) {
3
- return await executeScreenshot(config);
2
+ export async function handleScreenshot(config, capabilities) {
3
+ return await executeScreenshot(config, capabilities);
4
4
  }
@@ -1,10 +1,7 @@
1
1
  /**
2
2
  * zhihand_system tool handler — system navigation + media controls.
3
- *
4
- * Separated from zhihand_control to keep UI-control schema focused and
5
- * reduce LLM parameter hallucination (Gemini design review recommendation).
6
3
  */
7
- import type { ZhiHandConfig } from "../core/config.ts";
4
+ import type { ZhiHandRuntimeConfig } from "../core/config.ts";
8
5
  import type { SystemParams } from "../core/command.ts";
9
6
  type TextContent = {
10
7
  type: "text";
@@ -12,6 +9,7 @@ type TextContent = {
12
9
  };
13
10
  type ToolResult = {
14
11
  content: TextContent[];
12
+ isError?: boolean;
15
13
  };
16
- export declare function executeSystem(config: ZhiHandConfig, params: SystemParams): Promise<ToolResult>;
14
+ export declare function executeSystem(config: ZhiHandRuntimeConfig, params: SystemParams, platform: string): Promise<ToolResult>;
17
15
  export {};
@@ -1,11 +1,24 @@
1
1
  import { createSystemCommand, enqueueCommand, formatAckSummary } from "../core/command.js";
2
- import { waitForCommandAck } from "../core/sse.js";
3
- export async function executeSystem(config, params) {
4
- const command = createSystemCommand(params);
2
+ import { waitForCommandAck } from "../core/ws.js";
3
+ const IOS_ONLY = new Set(["siri", "control_center"]);
4
+ const ANDROID_ONLY = new Set(["open_browser", "shortcut_help"]);
5
+ export async function executeSystem(config, params, platform) {
6
+ // Platform guard
7
+ if (platform !== "ios" && IOS_ONLY.has(params.action)) {
8
+ return {
9
+ content: [{ type: "text", text: `Action ${params.action} is not supported on platform ${platform}` }],
10
+ isError: true,
11
+ };
12
+ }
13
+ if (platform !== "android" && ANDROID_ONLY.has(params.action)) {
14
+ return {
15
+ content: [{ type: "text", text: `Action ${params.action} is not supported on platform ${platform}` }],
16
+ isError: true,
17
+ };
18
+ }
19
+ const command = createSystemCommand(params, platform);
5
20
  const queued = await enqueueCommand(config, command);
6
21
  const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 15_000 });
7
22
  const summary = formatAckSummary(params.action, ack);
8
- return {
9
- content: [{ type: "text", text: summary }],
10
- };
23
+ return { content: [{ type: "text", text: summary }] };
11
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.29.0",
3
+ "version": "0.32.0",
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
  }