@zhihand/mcp 0.25.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ZhiHand MCP Server — let AI agents see and control your phone.
4
4
 
5
- Version: `0.25.0`
5
+ Version: `0.26.0`
6
6
 
7
7
  ## What is this?
8
8
 
@@ -177,7 +177,7 @@ When you switch:
177
177
 
178
178
  ## MCP Tools
179
179
 
180
- The server exposes three tools to AI agents:
180
+ The server exposes these tools to AI agents:
181
181
 
182
182
  ### `zhihand_control`
183
183
 
@@ -212,6 +212,12 @@ Capture the current phone screen without performing any action. Returns an image
212
212
 
213
213
  No parameters required.
214
214
 
215
+ ### `zhihand_status`
216
+
217
+ Get device status: platform, model, OS version, screen size, battery, network, BLE connection, dark mode, storage, and more. No parameters.
218
+
219
+ Tool description and `open_app` guidance are **automatically adapted** based on the connected device platform (Android/iOS), so AI agents always send correct platform-specific parameters.
220
+
215
221
  ### `zhihand_pair`
216
222
 
217
223
  Pair with a phone device. Returns a QR code and pairing URL.
@@ -220,6 +226,10 @@ Pair with a phone device. Returns a QR code and pairing URL.
220
226
  |---|---|---|
221
227
  | `forceNew` | `boolean` | Force new pairing even if already paired (default: `false`) |
222
228
 
229
+ ### MCP Resource: `device://profile`
230
+
231
+ Provides full device context (static + dynamic) as JSON. Includes platform, model, OS version, screen size, battery, network, BLE, dark mode, storage, thermal state, locale, and more.
232
+
223
233
  ## How It Works
224
234
 
225
235
  ```
@@ -302,12 +312,14 @@ packages/mcp/
302
312
  │ ├── core/
303
313
  │ │ ├── config.ts # Credential & config management (~/.zhihand/), default models
304
314
  │ │ ├── resolve-path.ts # Platform-aware executable path resolution (gemini/claude/codex)
315
+ │ │ ├── device.ts # Device context: static/dynamic profile, fetch, SSE updates
305
316
  │ │ ├── command.ts # Command creation, enqueue, ACK formatting
306
317
  │ │ ├── screenshot.ts # Binary screenshot fetch (JPEG)
307
318
  │ │ ├── sse.ts # SSE client + hybrid ACK (SSE push + polling fallback)
308
319
  │ │ └── pair.ts # Plugin registration + device pairing flow
309
320
  │ ├── daemon/
310
321
  │ │ ├── index.ts # Daemon entry: HTTP server + MCP + Relay + Config API
322
+ │ │ ├── logger.ts # Debug logger (--debug flag)
311
323
  │ │ ├── heartbeat.ts # Brain heartbeat loop (30s interval, 5s retry)
312
324
  │ │ ├── prompt-listener.ts # SSE + polling prompt listener with dedup
313
325
  │ │ └── dispatcher.ts # Async CLI dispatch (spawn + timeout + two-stage kill)
package/bin/zhihand CHANGED
@@ -311,8 +311,9 @@ switch (command) {
311
311
  { label: "3. Swipe up", type: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
312
312
  { label: "4. Swipe down", type: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
313
313
  { label: "5. Press Home", type: "hid", params: { action: "home" } },
314
- { label: "6. Press Back", type: "hid", params: { action: "back" } },
315
- { label: "7. Screenshot", type: "screenshot" },
314
+ { label: "6. Open WeChat", type: "hid", params: { action: "open_app", appPackage: "com.tencent.mm" } },
315
+ { label: "7. Press Back", type: "hid", params: { action: "back" } },
316
+ { label: "8. Screenshot", type: "screenshot" },
316
317
  ];
317
318
 
318
319
  let passed = 0;
@@ -342,7 +343,10 @@ switch (command) {
342
343
  const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
343
344
  const ms = Date.now() - t0;
344
345
  if (ack.acked) {
345
- console.log(`✅ (${ms}ms)`);
346
+ const ackStatus = ack.command?.ack_status ?? "ok";
347
+ const detail = ackStatus !== "ok" ? ` [${ackStatus}]` : "";
348
+ const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
349
+ console.log(`✅ (${ms}ms)${detail}${resultInfo}`);
346
350
  passed++;
347
351
  } else {
348
352
  console.log(`⏱️ Timeout (${ms}ms)`);
@@ -18,7 +18,6 @@ export interface ControlParams {
18
18
  appPackage?: string;
19
19
  bundleId?: string;
20
20
  urlScheme?: string;
21
- appName?: string;
22
21
  }
23
22
  export interface QueuedControlCommand {
24
23
  type: string;
@@ -1,3 +1,4 @@
1
+ import { getStaticContext, isDeviceProfileLoaded } from "./device.js";
1
2
  import { dbg } from "../daemon/logger.js";
2
3
  let messageCounter = 0;
3
4
  function nextMessageId() {
@@ -54,17 +55,34 @@ export function createControlCommand(params) {
54
55
  };
55
56
  case "open_app": {
56
57
  const appPayload = {};
57
- if (params.appPackage)
58
- appPayload.app_package = params.appPackage;
59
- if (params.bundleId)
60
- appPayload.bundle_id = params.bundleId;
61
- if (params.urlScheme)
62
- appPayload.url_scheme = params.urlScheme;
63
- if (params.appName)
64
- appPayload.app_name = params.appName;
58
+ const platform = isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
59
+ // Only send platform-appropriate fields — Android strict JSON rejects unknown keys
60
+ if (platform === "android") {
61
+ // Android: only app_package
62
+ if (params.appPackage)
63
+ appPayload.app_package = params.appPackage;
64
+ }
65
+ else if (platform === "ios") {
66
+ // iOS: bundleId or urlScheme
67
+ if (params.bundleId)
68
+ appPayload.bundle_id = params.bundleId;
69
+ if (params.urlScheme)
70
+ appPayload.url_scheme = params.urlScheme;
71
+ }
72
+ else {
73
+ // Unknown platform: send only what's provided, prefer appPackage
74
+ if (params.appPackage)
75
+ appPayload.app_package = params.appPackage;
76
+ else if (params.bundleId)
77
+ appPayload.bundle_id = params.bundleId;
78
+ else if (params.urlScheme)
79
+ appPayload.url_scheme = params.urlScheme;
80
+ }
81
+ // Never send app_name — phone strict JSON parser rejects unknown keys
65
82
  if (!appPayload.app_package && !appPayload.bundle_id && !appPayload.url_scheme) {
66
83
  throw new Error("open_app requires at least one of: appPackage, bundleId, urlScheme");
67
84
  }
85
+ dbg(`[cmd] open_app: platform=${platform}, payload=${JSON.stringify(appPayload)}`);
68
86
  return { type: "receive_app", payload: appPayload };
69
87
  }
70
88
  case "screenshot":
@@ -104,7 +122,8 @@ export async function getCommand(config, commandId) {
104
122
  throw new Error(`Get command failed: ${response.status}`);
105
123
  }
106
124
  const payload = (await response.json());
107
- dbg(`[cmd] Got: id=${payload.command.id}, status=${payload.command.status}, acked=${!!payload.command.acked_at}`);
125
+ const cmd = payload.command;
126
+ dbg(`[cmd] Got: id=${cmd.id}, status=${cmd.status}, acked=${!!cmd.acked_at}, ack_status=${cmd.ack_status ?? "-"}, ack_result=${JSON.stringify(cmd.ack_result ?? null)}`);
108
127
  return payload.command;
109
128
  }
110
129
  export function formatAckSummary(action, result) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Device Context — static + dynamic device info fetched from control plane.
3
+ *
4
+ * Static info (platform, model, screen size) is set once after pairing and
5
+ * injected into MCP tool descriptions so the LLM always knows the device.
6
+ *
7
+ * Dynamic info (battery, network, BLE) is updated via SSE push and exposed
8
+ * through the zhihand_status tool and device://profile resource.
9
+ */
10
+ import type { ZhiHandConfig } from "./config.ts";
11
+ export interface StaticContext {
12
+ platform: string;
13
+ model: string;
14
+ osVersion: string;
15
+ screenWidthPx: number;
16
+ screenHeightPx: number;
17
+ density: number;
18
+ formFactor: string;
19
+ locale: string;
20
+ textDirection: string;
21
+ timezone: string;
22
+ navigationMode?: string;
23
+ romFamily?: string;
24
+ }
25
+ export interface DynamicContext {
26
+ batteryLevel: number;
27
+ batteryState: string;
28
+ networkType: string;
29
+ bleRssi: number | null;
30
+ darkMode: boolean;
31
+ hidConnected: boolean;
32
+ recordingActive: boolean;
33
+ appInForeground: boolean;
34
+ availableStorageMb: number;
35
+ thermalState?: string;
36
+ fontScale: number;
37
+ }
38
+ export declare function getStaticContext(): StaticContext;
39
+ export declare function getDynamicContext(): DynamicContext;
40
+ export declare function isDeviceProfileLoaded(): boolean;
41
+ export declare function extractStatic(profile: Record<string, unknown>): StaticContext;
42
+ export declare function extractDynamic(profile: Record<string, unknown>): DynamicContext;
43
+ export declare function updateDeviceProfile(raw: Record<string, unknown>): void;
44
+ export declare function fetchDeviceProfile(config: ZhiHandConfig): Promise<void>;
45
+ export declare function buildControlToolDescription(): string;
46
+ export declare function buildScreenshotToolDescription(): string;
47
+ export declare function formatDeviceStatus(): Record<string, unknown>;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Device Context — static + dynamic device info fetched from control plane.
3
+ *
4
+ * Static info (platform, model, screen size) is set once after pairing and
5
+ * injected into MCP tool descriptions so the LLM always knows the device.
6
+ *
7
+ * Dynamic info (battery, network, BLE) is updated via SSE push and exposed
8
+ * through the zhihand_status tool and device://profile resource.
9
+ */
10
+ import { dbg } from "../daemon/logger.js";
11
+ // ── Default values ────────────────────────────────────────
12
+ const DEFAULT_STATIC = {
13
+ platform: "unknown",
14
+ model: "unknown",
15
+ osVersion: "unknown",
16
+ screenWidthPx: 0,
17
+ screenHeightPx: 0,
18
+ density: 1,
19
+ formFactor: "phone",
20
+ locale: "en-US",
21
+ textDirection: "ltr",
22
+ timezone: "UTC",
23
+ };
24
+ const DEFAULT_DYNAMIC = {
25
+ batteryLevel: -1,
26
+ batteryState: "unknown",
27
+ networkType: "unknown",
28
+ bleRssi: null,
29
+ darkMode: false,
30
+ hidConnected: false,
31
+ recordingActive: false,
32
+ appInForeground: false,
33
+ availableStorageMb: -1,
34
+ fontScale: 1,
35
+ };
36
+ // ── Module state ──────────────────────────────────────────
37
+ let staticCtx = { ...DEFAULT_STATIC };
38
+ let dynamicCtx = { ...DEFAULT_DYNAMIC };
39
+ let loaded = false;
40
+ export function getStaticContext() {
41
+ return staticCtx;
42
+ }
43
+ export function getDynamicContext() {
44
+ return dynamicCtx;
45
+ }
46
+ export function isDeviceProfileLoaded() {
47
+ return loaded;
48
+ }
49
+ // ── Extract helpers ───────────────────────────────────────
50
+ function str(v, fallback) {
51
+ return typeof v === "string" && v ? v : fallback;
52
+ }
53
+ function num(v, fallback) {
54
+ return typeof v === "number" && !isNaN(v) ? v : fallback;
55
+ }
56
+ function bool(v, fallback) {
57
+ return typeof v === "boolean" ? v : fallback;
58
+ }
59
+ export function extractStatic(profile) {
60
+ // Build OS version string from platform + system_release + api_level
61
+ const platform = str(profile.platform, DEFAULT_STATIC.platform);
62
+ const sysRelease = str(profile.system_release, "");
63
+ const apiLevel = typeof profile.api_level === "number" ? profile.api_level : null;
64
+ let osVersion;
65
+ if (platform === "android" && sysRelease && apiLevel) {
66
+ osVersion = `Android ${sysRelease} (API ${apiLevel})`;
67
+ }
68
+ else if (platform === "ios" && sysRelease) {
69
+ osVersion = `iOS ${sysRelease}`;
70
+ }
71
+ else {
72
+ osVersion = sysRelease || DEFAULT_STATIC.osVersion;
73
+ }
74
+ // Screen size: Android uses display_width_px/display_height_px, iOS uses display_width_pixels/display_height_pixels
75
+ const screenW = num(profile.display_width_px, num(profile.display_width_pixels, DEFAULT_STATIC.screenWidthPx));
76
+ const screenH = num(profile.display_height_px, num(profile.display_height_pixels, DEFAULT_STATIC.screenHeightPx));
77
+ // Density: Android uses density, iOS uses display_scale
78
+ const density = num(profile.density, num(profile.display_scale, DEFAULT_STATIC.density));
79
+ // Text direction: rtl is boolean
80
+ const textDirection = profile.rtl === true ? "rtl" : "ltr";
81
+ return {
82
+ platform,
83
+ model: str(profile.model, DEFAULT_STATIC.model),
84
+ osVersion,
85
+ screenWidthPx: screenW,
86
+ screenHeightPx: screenH,
87
+ density,
88
+ formFactor: str(profile.form_factor, DEFAULT_STATIC.formFactor),
89
+ locale: str(profile.locale, DEFAULT_STATIC.locale),
90
+ textDirection,
91
+ timezone: str(profile.timezone, DEFAULT_STATIC.timezone),
92
+ navigationMode: typeof profile.navigation_mode === "string" ? profile.navigation_mode : undefined,
93
+ romFamily: typeof profile.rom_family === "string" ? profile.rom_family : undefined,
94
+ };
95
+ }
96
+ export function extractDynamic(profile) {
97
+ return {
98
+ batteryLevel: num(profile.battery_level, DEFAULT_DYNAMIC.batteryLevel),
99
+ batteryState: str(profile.battery_state, DEFAULT_DYNAMIC.batteryState),
100
+ networkType: str(profile.network_type, DEFAULT_DYNAMIC.networkType),
101
+ bleRssi: typeof profile.ble_rssi === "number" ? profile.ble_rssi : null,
102
+ darkMode: bool(profile.dark_mode, DEFAULT_DYNAMIC.darkMode),
103
+ hidConnected: bool(profile.hid_connected, DEFAULT_DYNAMIC.hidConnected),
104
+ recordingActive: bool(profile.recording_active, DEFAULT_DYNAMIC.recordingActive),
105
+ appInForeground: bool(profile.app_in_foreground, DEFAULT_DYNAMIC.appInForeground),
106
+ availableStorageMb: num(profile.available_storage_mb, DEFAULT_DYNAMIC.availableStorageMb),
107
+ thermalState: typeof profile.thermal_state === "string" ? profile.thermal_state : undefined,
108
+ fontScale: num(profile.font_scale, DEFAULT_DYNAMIC.fontScale),
109
+ };
110
+ }
111
+ // ── Update from SSE event ─────────────────────────────────
112
+ export function updateDeviceProfile(raw) {
113
+ // SSE events may also wrap in { platform, attributes: {...} } — flatten if needed
114
+ let profile;
115
+ if (typeof raw.attributes === "object" && raw.attributes !== null) {
116
+ const attrs = raw.attributes;
117
+ profile = { ...attrs, platform: raw.platform ?? attrs.platform };
118
+ }
119
+ else {
120
+ profile = raw;
121
+ }
122
+ staticCtx = extractStatic(profile);
123
+ dynamicCtx = extractDynamic(profile);
124
+ loaded = true;
125
+ dbg(`[device] Profile updated: platform=${staticCtx.platform}, model=${staticCtx.model}, screen=${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`);
126
+ }
127
+ // ── Fetch initial profile from API ────────────────────────
128
+ export async function fetchDeviceProfile(config) {
129
+ const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/device-profile`;
130
+ dbg(`[device] Fetching profile: GET ${url}`);
131
+ try {
132
+ const response = await fetch(url, {
133
+ headers: { "x-zhihand-controller-token": config.controllerToken },
134
+ signal: AbortSignal.timeout(10_000),
135
+ });
136
+ if (!response.ok) {
137
+ dbg(`[device] Profile fetch failed: ${response.status} ${response.statusText}`);
138
+ return;
139
+ }
140
+ const data = await response.json();
141
+ // API returns { profile: { credential_id, platform, attributes: {...} } }
142
+ const wrapper = (typeof data.profile === "object" && data.profile !== null)
143
+ ? data.profile
144
+ : data;
145
+ // Merge top-level fields (platform, edge_id) with attributes for flat extraction
146
+ const attrs = (typeof wrapper.attributes === "object" && wrapper.attributes !== null)
147
+ ? wrapper.attributes
148
+ : {};
149
+ const profile = { ...attrs, platform: wrapper.platform ?? attrs.platform };
150
+ dbg(`[device] Raw profile keys: ${Object.keys(profile).join(", ")}`);
151
+ updateDeviceProfile(profile);
152
+ }
153
+ catch (err) {
154
+ dbg(`[device] Profile fetch error: ${err.message}`);
155
+ }
156
+ }
157
+ // ── Build tool description with device info ───────────────
158
+ export function buildControlToolDescription() {
159
+ if (!loaded || staticCtx.platform === "unknown") {
160
+ return "Control the connected mobile device. Supports click, swipe, type, scroll, open_app, back, home, and more. All coordinates use normalized ratios [0,1].";
161
+ }
162
+ const parts = [
163
+ `Control a ${staticCtx.platform} device`,
164
+ `(${staticCtx.model}, ${staticCtx.osVersion}`,
165
+ `${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
166
+ `${staticCtx.formFactor}, ${staticCtx.locale})`,
167
+ ];
168
+ let desc = parts.join(", ") + ".";
169
+ desc += " All coordinates use normalized ratios [0,1].";
170
+ // Platform-specific open_app guidance
171
+ if (staticCtx.platform === "android") {
172
+ desc += " For open_app, use appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme.";
173
+ }
174
+ else if (staticCtx.platform === "ios") {
175
+ desc += " For open_app, use bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage.";
176
+ }
177
+ return desc;
178
+ }
179
+ export function buildScreenshotToolDescription() {
180
+ if (!loaded || staticCtx.platform === "unknown") {
181
+ return "Take a screenshot of the phone screen.";
182
+ }
183
+ return `Take a screenshot of the ${staticCtx.platform} device (${staticCtx.model}, ${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}).`;
184
+ }
185
+ // ── Format status for zhihand_status tool ─────────────────
186
+ export function formatDeviceStatus() {
187
+ return {
188
+ platform: staticCtx.platform,
189
+ model: staticCtx.model,
190
+ os_version: staticCtx.osVersion,
191
+ screen: `${staticCtx.screenWidthPx}x${staticCtx.screenHeightPx}`,
192
+ density: staticCtx.density,
193
+ form_factor: staticCtx.formFactor,
194
+ locale: staticCtx.locale,
195
+ timezone: staticCtx.timezone,
196
+ navigation_mode: staticCtx.navigationMode ?? null,
197
+ battery: `${dynamicCtx.batteryLevel}% (${dynamicCtx.batteryState})`,
198
+ network: dynamicCtx.networkType,
199
+ ble: dynamicCtx.hidConnected
200
+ ? `connected${dynamicCtx.bleRssi !== null ? ` (RSSI: ${dynamicCtx.bleRssi})` : ""}`
201
+ : "disconnected",
202
+ dark_mode: dynamicCtx.darkMode,
203
+ storage_available_mb: dynamicCtx.availableStorageMb,
204
+ thermal: dynamicCtx.thermalState ?? "normal",
205
+ font_scale: dynamicCtx.fontScale,
206
+ };
207
+ }
package/dist/core/sse.js CHANGED
@@ -10,7 +10,7 @@ export function handleSSEEvent(event) {
10
10
  if (event.kind === "command.acked" && event.command) {
11
11
  const callback = ackCallbacks.get(event.command.id);
12
12
  if (callback) {
13
- dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}`);
13
+ dbg(`[sse-cmd] ACK callback for ${event.command.id}, ack_status=${event.command.ack_status}, ack_result=${JSON.stringify(event.command.ack_result ?? null)}`);
14
14
  callback(event.command);
15
15
  ackCallbacks.delete(event.command.id);
16
16
  }
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { DEFAULT_MODELS } from "../core/config.js";
7
7
  import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
8
+ import { getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
8
9
  import { dbg } from "./logger.js";
9
10
  const CLI_TIMEOUT = 300_000; // 300s (5min) per prompt — MCP tool chains need multiple turns
10
11
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
@@ -351,7 +352,30 @@ export async function killActiveChild() {
351
352
  conversationHistory.length = 0;
352
353
  }
353
354
  // ── System Prompt ─────────────────────────────────────────
354
- const SYSTEM_CONTEXT = `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
355
+ /**
356
+ * Build system context dynamically — injects device platform info when available
357
+ * so the AI sends correct platform-specific parameters (e.g. appPackage vs bundleId).
358
+ */
359
+ function buildSystemContext() {
360
+ const static_ = isDeviceProfileLoaded() ? getStaticContext() : null;
361
+ const deviceLine = static_
362
+ ? `Connected device: ${static_.platform} ${static_.model} (${static_.osVersion}), ${static_.screenWidthPx}x${static_.screenHeightPx}, ${static_.formFactor}, ${static_.locale}`
363
+ : "Connected device: unknown platform";
364
+ // Platform-specific open_app guidance
365
+ let openAppDoc;
366
+ if (static_?.platform === "android") {
367
+ openAppDoc = "- open_app: Open an app. Params: appPackage (e.g. 'com.tencent.mm'). Do NOT send bundleId or urlScheme on Android.";
368
+ }
369
+ else if (static_?.platform === "ios") {
370
+ openAppDoc = "- open_app: Open an app. Params: bundleId (e.g. 'com.tencent.xin') or urlScheme (e.g. 'weixin://'). Do NOT send appPackage on iOS.";
371
+ }
372
+ else {
373
+ openAppDoc = "- open_app: Open an app. Params: appPackage (Android, e.g. 'com.tencent.mm'), bundleId (iOS), urlScheme (e.g. 'weixin://')";
374
+ }
375
+ return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
376
+
377
+ ## Device
378
+ ${deviceLine}
355
379
 
356
380
  ## Available MCP Tools
357
381
 
@@ -372,22 +396,26 @@ Control the phone. Requires "action" parameter. All coordinates use normalized r
372
396
  - back: Press Back button (no params)
373
397
  - home: Press Home button (no params)
374
398
  - enter: Press Enter key (no params)
375
- - open_app: Open an app. Params: appPackage (Android, e.g. "com.tencent.mm"), bundleId (iOS), urlScheme (e.g. "weixin://")
399
+ ${openAppDoc}
376
400
  - clipboard: Read/write clipboard. Params: clipboardAction ("get"/"set"), text
377
401
  - screenshot: Capture screen via control (same as zhihand_screenshot)
378
402
  - wait: Wait before next action. Params: durationMs (default 1000)
379
403
 
404
+ ### zhihand_status
405
+ Get device status: platform, battery, network, BLE connection, dark mode, storage, etc.
406
+
380
407
  ## Rules
381
408
  - When the user asks to see their screen, ALWAYS call zhihand_screenshot first.
382
409
  - When the user asks to open an app (e.g. WeChat, Settings), use open_app action.
383
410
  - When the user asks to go back/home, use back/home actions.
384
411
  - For all tap/click operations, use xRatio and yRatio (0-1 normalized coordinates based on the screenshot).`;
412
+ }
385
413
  /**
386
414
  * Build the full system prompt with optional conversation history.
387
415
  * Used for first prompt in persistent sessions and all one-shot calls.
388
416
  */
389
417
  function wrapPrompt(userPrompt, history) {
390
- let result = SYSTEM_CONTEXT;
418
+ let result = buildSystemContext();
391
419
  if (history && history.length > 0) {
392
420
  result += "\n\n## Recent Conversation\n";
393
421
  for (const turn of history) {
@@ -11,6 +11,7 @@ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline, setBrainMeta }
11
11
  import { PromptListener } from "./prompt-listener.js";
12
12
  import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
13
13
  import { setDebugEnabled, dbg } from "./logger.js";
14
+ import { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded } from "../core/device.js";
14
15
  const DEFAULT_PORT = 18686;
15
16
  const PID_FILE = "daemon.pid";
16
17
  // ── State ──────────────────────────────────────────────────
@@ -189,6 +190,15 @@ export async function startDaemon(options) {
189
190
  else {
190
191
  log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
191
192
  }
193
+ // Fetch device profile (platform, model, screen size) — non-blocking, best-effort
194
+ await fetchDeviceProfile(config);
195
+ if (isDeviceProfileLoaded()) {
196
+ const s = getStaticContext();
197
+ log(`[device] ${s.platform} ${s.model} (${s.osVersion}), ${s.screenWidthPx}x${s.screenHeightPx}, ${s.locale}`);
198
+ }
199
+ else {
200
+ log(`[device] Device profile not available — tool descriptions will use generic defaults`);
201
+ }
192
202
  // MCP sessions: each client gets its own McpServer + Transport pair
193
203
  // because McpServer.connect() can only be called once per instance
194
204
  const MAX_MCP_SESSIONS = 20;
@@ -1,3 +1,4 @@
1
+ import { updateDeviceProfile } from "../core/device.js";
1
2
  import { dbg } from "./logger.js";
2
3
  const SSE_WATCHDOG_TIMEOUT = 120_000; // 120s no data → reconnect (servers may not send keepalive frequently)
3
4
  const SSE_RECONNECT_DELAY = 3_000;
@@ -131,6 +132,13 @@ export class PromptListener {
131
132
  }
132
133
  }
133
134
  }
135
+ else if (event.kind === "device_profile.updated" && event.device_profile) {
136
+ // NOTE: This event may only arrive if the server sends cross-topic events on
137
+ // the prompts stream, or if the API is updated to support multi-topic SSE.
138
+ // If not received, device profile is still fetched at daemon startup.
139
+ this.log("[device] Device profile updated via SSE");
140
+ updateDeviceProfile(event.device_profile);
141
+ }
134
142
  }
135
143
  startPolling() {
136
144
  if (this.pollTimer)
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.25.0";
2
+ export declare const PACKAGE_VERSION = "0.26.2";
3
3
  export declare function createServer(deviceName?: string): McpServer;
4
4
  export declare function startStdioServer(deviceName?: string): Promise<void>;
package/dist/index.js CHANGED
@@ -5,29 +5,60 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
5
5
  import { executeControl } from "./tools/control.js";
6
6
  import { handleScreenshot } from "./tools/screenshot.js";
7
7
  import { handlePair } from "./tools/pair.js";
8
- export const PACKAGE_VERSION = "0.25.0";
8
+ import { getStaticContext, getDynamicContext, fetchDeviceProfile, buildControlToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
9
+ export const PACKAGE_VERSION = "0.26.2";
9
10
  export function createServer(deviceName) {
10
11
  const server = new McpServer({
11
12
  name: "zhihand",
12
13
  version: PACKAGE_VERSION,
13
14
  });
14
15
  // zhihand_control — main phone control tool
15
- server.tool("zhihand_control", controlSchema, async (params) => {
16
+ // Description includes device info (platform, model, screen size) when available
17
+ server.tool("zhihand_control", buildControlToolDescription(), controlSchema, async (params) => {
16
18
  const config = resolveConfig(deviceName);
17
19
  return await executeControl(config, params);
18
20
  });
19
21
  // zhihand_screenshot — capture current screen without any action
20
- server.tool("zhihand_screenshot", screenshotSchema, async () => {
22
+ server.tool("zhihand_screenshot", buildScreenshotToolDescription(), screenshotSchema, async () => {
21
23
  const config = resolveConfig(deviceName);
22
24
  return await handleScreenshot(config);
23
25
  });
26
+ // zhihand_status — return device context for LLM to query on demand
27
+ server.tool("zhihand_status", "Get device status: platform, model, OS version, screen size, battery, network, BLE, dark mode, storage, and more.", {}, async () => {
28
+ return {
29
+ content: [{
30
+ type: "text",
31
+ text: JSON.stringify(formatDeviceStatus(), null, 2),
32
+ }],
33
+ };
34
+ });
24
35
  // zhihand_pair — device pairing
25
- server.tool("zhihand_pair", pairSchema, async (params) => {
36
+ server.tool("zhihand_pair", "Pair a new mobile device via QR code.", pairSchema, async (params) => {
26
37
  return await handlePair(params);
27
38
  });
39
+ // device://profile — MCP resource for device profile
40
+ server.resource("device-profile", "device://profile", { description: "Device static and dynamic context (platform, model, screen, battery, network, etc.)" }, async () => {
41
+ const staticCtx = getStaticContext();
42
+ const dynamicCtx = getDynamicContext();
43
+ return {
44
+ contents: [{
45
+ uri: "device://profile",
46
+ mimeType: "application/json",
47
+ text: JSON.stringify({ ...staticCtx, ...dynamicCtx }, null, 2),
48
+ }],
49
+ };
50
+ });
28
51
  return server;
29
52
  }
30
53
  export async function startStdioServer(deviceName) {
54
+ // Fetch device profile before creating server so tool descriptions have platform info
55
+ try {
56
+ const config = resolveConfig(deviceName);
57
+ await fetchDeviceProfile(config);
58
+ }
59
+ catch {
60
+ // Non-fatal — server will use generic descriptions
61
+ }
31
62
  const server = createServer(deviceName);
32
63
  const transport = new StdioServerTransport();
33
64
  await server.connect(transport);
@@ -16,7 +16,6 @@ export declare const controlSchema: {
16
16
  appPackage: z.ZodOptional<z.ZodString>;
17
17
  bundleId: z.ZodOptional<z.ZodString>;
18
18
  urlScheme: z.ZodOptional<z.ZodString>;
19
- appName: z.ZodOptional<z.ZodString>;
20
19
  };
21
20
  export declare const screenshotSchema: {};
22
21
  export declare const pairSchema: {
@@ -22,7 +22,6 @@ export const controlSchema = {
22
22
  appPackage: z.string().optional().describe("Android package name, e.g. 'com.tencent.mm'"),
23
23
  bundleId: z.string().optional().describe("iOS bundle ID, e.g. 'com.tencent.xin'"),
24
24
  urlScheme: z.string().optional().describe("URL scheme, e.g. 'weixin://'"),
25
- appName: z.string().optional().describe("Human-readable app name (for logging)"),
26
25
  };
27
26
  export const screenshotSchema = {};
28
27
  export const pairSchema = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.25.0",
3
+ "version": "0.26.2",
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",