@zhihand/mcp 0.26.4 → 0.28.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/bin/zhihand CHANGED
@@ -53,7 +53,10 @@ Usage:
53
53
  zhihand pair Pair with a phone device
54
54
  zhihand detect Detect available CLI tools
55
55
 
56
- zhihand test Test device connectivity (sends click + type commands)
56
+ zhihand list List all available tests with IDs
57
+ zhihand test Run all safe device tests
58
+ zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
59
+ zhihand test all Run ALL tests (including unsafe, e.g. power button)
57
60
  zhihand serve Start MCP Server (stdio mode, backward compat)
58
61
 
59
62
  Options:
@@ -286,13 +289,97 @@ switch (command) {
286
289
  break;
287
290
  }
288
291
 
292
+ case "list":
289
293
  case "test": {
290
294
  const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
291
- const { createControlCommand, enqueueCommand } = await import("../dist/core/command.js");
295
+ const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
292
296
  const { waitForCommandAck } = await import("../dist/core/sse.js");
293
297
  const { fetchScreenshotBinary } = await import("../dist/core/screenshot.js");
294
298
  const { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded, formatDeviceStatus } = await import("../dist/core/device.js");
295
299
 
300
+ // ── Test Registry ────────────────────────────────────────
301
+ // Kind: "profile" | "status" | "screenshot" | "hid" | "system"
302
+ // Platform: undefined | "android" | "ios" (skipped on non-matching)
303
+ // Unsafe: won't run in full-suite unless explicitly requested
304
+ const REGISTRY = [
305
+ // Phase A — Device Info API
306
+ { id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
307
+ { id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
308
+ // Phase B — Screenshot
309
+ { id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
310
+ // Phase C — Tap / Touch
311
+ { id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
312
+ { id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
313
+ { id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
314
+ { id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
315
+ { id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
316
+ // Phase D — Swipe / Scroll
317
+ { id: 9, phase: "Swipe/Scroll", label: "Swipe up", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
318
+ { id: 10, phase: "Swipe/Scroll", label: "Swipe down", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
319
+ { id: 11, phase: "Swipe/Scroll", label: "Swipe left", kind: "hid", params: { action: "swipe", startXRatio: 0.7, startYRatio: 0.5, endXRatio: 0.3, endYRatio: 0.5, durationMs: 300 } },
320
+ { id: 12, phase: "Swipe/Scroll", label: "Swipe right", kind: "hid", params: { action: "swipe", startXRatio: 0.3, startYRatio: 0.5, endXRatio: 0.7, endYRatio: 0.5, durationMs: 300 } },
321
+ { id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
322
+ { id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
323
+ // Phase E — Text + Keys
324
+ { id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
325
+ { id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
326
+ { id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
327
+ // Phase F — App Navigation
328
+ { id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
329
+ { id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
330
+ { id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
331
+ // Phase G — Clipboard
332
+ { id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
333
+ // Phase H — System Navigation
334
+ { id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
335
+ { id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
336
+ { id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
337
+ { id: 25, phase: "System Nav", label: "Switch input", kind: "system", params: { action: "switch_input" } },
338
+ { id: 26, phase: "System Nav", label: "Siri", kind: "system", params: { action: "siri" }, platform: "ios" },
339
+ { id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
340
+ { id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
341
+ { id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
342
+ // Phase I — Media
343
+ { id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
344
+ { id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
345
+ { id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
346
+ { id: 33, phase: "Media", label: "Play/Pause", kind: "system", params: { action: "play_pause" } },
347
+ { id: 34, phase: "Media", label: "Next track", kind: "system", params: { action: "next_track" } },
348
+ { id: 35, phase: "Media", label: "Prev track", kind: "system", params: { action: "prev_track" } },
349
+ { id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
350
+ { id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
351
+ { id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
352
+ // Phase J — Hardware
353
+ { id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
354
+ { id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
355
+ { id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
356
+ ];
357
+
358
+ // ── "list" sub-command ───────────────────────────────────
359
+ if (command === "list") {
360
+ console.log("📋 ZhiHand Test Registry\n");
361
+ let currentPhase = "";
362
+ for (const t of REGISTRY) {
363
+ if (t.phase !== currentPhase) {
364
+ console.log(`\n ── ${t.phase} ──`);
365
+ currentPhase = t.phase;
366
+ }
367
+ const tags = [];
368
+ if (t.platform) tags.push(`${t.platform}-only`);
369
+ if (t.unsafe) tags.push("unsafe");
370
+ const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
371
+ console.log(` ${String(t.id).padStart(2)}. ${t.label}${tagStr}`);
372
+ }
373
+ console.log(`\n Total: ${REGISTRY.length} tests`);
374
+ console.log("\nUsage:");
375
+ console.log(" zhihand test # run all safe tests");
376
+ console.log(" zhihand test 4 # run test #4 only");
377
+ console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
378
+ console.log(" zhihand test all # run ALL tests (including unsafe)");
379
+ process.exit(0);
380
+ }
381
+
382
+ // ── "test" sub-command ───────────────────────────────────
296
383
  let testConfig;
297
384
  try {
298
385
  testConfig = resolveTestConfig(values.device ?? process.env.ZHIHAND_DEVICE);
@@ -302,152 +389,219 @@ switch (command) {
302
389
  process.exit(1);
303
390
  }
304
391
 
392
+ // Parse which tests to run from positional args
393
+ const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
394
+ let selectedIds = null; // null = default (all safe)
395
+ let includeUnsafe = false;
396
+ if (filterArg) {
397
+ if (filterArg === "all") {
398
+ includeUnsafe = true;
399
+ } else {
400
+ selectedIds = new Set(
401
+ filterArg.split(",").map((s) => {
402
+ const trimmed = s.trim();
403
+ const n = Number(trimmed);
404
+ // Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
405
+ return Number.isInteger(n) && n > 0 ? n : NaN;
406
+ }).filter((n) => !isNaN(n))
407
+ );
408
+ if (selectedIds.size === 0) {
409
+ console.error(`Invalid test IDs: ${filterArg}`);
410
+ console.error("Run 'zhihand list' to see available tests.");
411
+ process.exit(1);
412
+ }
413
+ // Explicit selection implies user knows what they're doing
414
+ includeUnsafe = true;
415
+ }
416
+ }
417
+
305
418
  console.log("🧪 ZhiHand Device Test");
306
419
  console.log(` Device: ${testConfig.credentialId}`);
307
420
  console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
308
421
 
422
+ // Pre-fetch device profile so platform-aware tests work.
423
+ // Note: platform is read dynamically via getDevicePlatform() so Test 1
424
+ // (Fetch device profile) can populate it before later tests consume it.
425
+ try {
426
+ await fetchDeviceProfile(testConfig);
427
+ } catch { /* non-fatal — Test 1 will retry and platform will update */ }
428
+ const getDevicePlatform = () => isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
429
+
309
430
  let passed = 0;
310
431
  let failed = 0;
432
+ let skipped = 0;
311
433
  let totalSteps = 0;
312
434
 
313
- // ── Helper: run a single HID step ──
314
- async function runHidStep(label, params) {
435
+ // ── Resolve platform-aware params (evaluated at test time) ──
436
+ function resolvePlatformAwareParams(variant) {
437
+ const platform = getDevicePlatform();
438
+ if (variant === "open_wechat") {
439
+ return platform === "ios"
440
+ ? { action: "open_app", bundleId: "com.tencent.xin" }
441
+ : { action: "open_app", appPackage: "com.tencent.mm" };
442
+ }
443
+ if (variant === "clipboard_set") {
444
+ return { action: "clipboard", text: `zhihand_test_${Date.now()}` };
445
+ }
446
+ if (variant === "select_all") {
447
+ const keys = platform === "ios" ? "cmd+a" : "ctrl+a";
448
+ return { action: "keycombo", keys };
449
+ }
450
+ return null;
451
+ }
452
+
453
+ // ── Test runners (shared ACK logic) ──
454
+ async function runCommandTest(t, command) {
315
455
  totalSteps++;
316
- process.stdout.write(` ${label}... `);
456
+ process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
317
457
  const t0 = Date.now();
318
458
  try {
319
- const cmd = createControlCommand(params);
320
- const queued = await enqueueCommand(testConfig, cmd);
459
+ const queued = await enqueueCommand(testConfig, command);
321
460
  const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
322
461
  const ms = Date.now() - t0;
323
462
  if (ack.acked) {
324
463
  const ackStatus = ack.command?.ack_status ?? "ok";
325
- const detail = ackStatus !== "ok" ? ` [${ackStatus}]` : "";
326
464
  const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
327
- console.log(`✅ (${ms}ms)${detail}${resultInfo}`);
328
- passed++;
329
- return ack;
465
+ if (ackStatus === "ok") {
466
+ console.log(`✅ (${ms}ms)${resultInfo}`);
467
+ passed++;
468
+ } else {
469
+ console.log(`❌ [${ackStatus}] (${ms}ms)${resultInfo}`);
470
+ failed++;
471
+ }
330
472
  } else {
331
473
  console.log(`⏱️ Timeout (${ms}ms)`);
332
474
  failed++;
333
- return null;
334
475
  }
335
476
  } catch (err) {
336
477
  console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
337
478
  failed++;
338
- return null;
339
479
  }
340
480
  }
341
481
 
342
- // ── Helper: run a screenshot step ──
343
- async function runScreenshotStep(label) {
344
- totalSteps++;
345
- process.stdout.write(` ${label}... `);
346
- const t0 = Date.now();
347
- try {
348
- const cmd = createControlCommand({ action: "screenshot" });
349
- const queued = await enqueueCommand(testConfig, cmd);
350
- const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
351
- if (ack.acked) {
352
- const buf = await fetchScreenshotBinary(testConfig);
353
- const ms = Date.now() - t0;
354
- console.log(`✅ ${(buf.length / 1024).toFixed(0)}KB (${ms}ms)`);
355
- passed++;
356
- } else {
357
- console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
358
- failed++;
482
+ async function runSingleTest(t) {
483
+ // Platform skip (evaluated at test time — Test 1 may have just populated the profile)
484
+ const currentPlatform = getDevicePlatform();
485
+ if (t.platform && t.platform !== currentPlatform) {
486
+ totalSteps++;
487
+ skipped++;
488
+ console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
489
+ return;
490
+ }
491
+
492
+ switch (t.kind) {
493
+ case "profile": {
494
+ totalSteps++;
495
+ process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
496
+ const t0 = Date.now();
497
+ try {
498
+ await fetchDeviceProfile(testConfig);
499
+ const ms = Date.now() - t0;
500
+ if (isDeviceProfileLoaded()) {
501
+ const s = getStaticContext();
502
+ console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
503
+ passed++;
504
+ } else {
505
+ console.log(`⚠️ Loaded but empty (${ms}ms)`);
506
+ failed++;
507
+ }
508
+ } catch (err) {
509
+ console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
510
+ failed++;
511
+ }
512
+ break;
513
+ }
514
+ case "status": {
515
+ totalSteps++;
516
+ process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
517
+ try {
518
+ const status = formatDeviceStatus();
519
+ const ignoredDefaults = new Set(["unknown", "0x0", "-1% (unknown)", "0"]);
520
+ const fields = Object.keys(status).filter((k) => {
521
+ const v = status[k];
522
+ if (v === null || v === undefined) return false;
523
+ if (ignoredDefaults.has(String(v))) return false;
524
+ return true;
525
+ });
526
+ console.log(`✅ ${fields.length} fields (${fields.join(", ")})`);
527
+ passed++;
528
+ } catch (err) {
529
+ console.log(`❌ ${err.message}`);
530
+ failed++;
531
+ }
532
+ break;
533
+ }
534
+ case "screenshot": {
535
+ totalSteps++;
536
+ process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
537
+ const t0 = Date.now();
538
+ try {
539
+ const cmd = createControlCommand({ action: "screenshot" });
540
+ const queued = await enqueueCommand(testConfig, cmd);
541
+ const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
542
+ if (ack.acked) {
543
+ const buf = await fetchScreenshotBinary(testConfig);
544
+ console.log(`✅ ${(buf.length / 1024).toFixed(0)}KB (${Date.now() - t0}ms)`);
545
+ passed++;
546
+ } else {
547
+ console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
548
+ failed++;
549
+ }
550
+ } catch (err) {
551
+ console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
552
+ failed++;
553
+ }
554
+ break;
555
+ }
556
+ case "hid": {
557
+ const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
558
+ await runCommandTest(t, createControlCommand(params));
559
+ break;
560
+ }
561
+ case "system": {
562
+ await runCommandTest(t, createSystemCommand(t.params));
563
+ break;
359
564
  }
360
- } catch (err) {
361
- console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
362
- failed++;
363
565
  }
364
566
  }
365
567
 
366
568
  const pause = () => new Promise((r) => setTimeout(r, 1500));
367
569
 
368
- // ── Phase 1: Device Profile ──────────────────────────────
369
- console.log(" ── Phase 1: Device Info ──");
370
- totalSteps++;
371
- process.stdout.write(" 1. Fetch device profile... ");
372
- {
373
- const t0 = Date.now();
374
- try {
375
- await fetchDeviceProfile(testConfig);
376
- const ms = Date.now() - t0;
377
- if (isDeviceProfileLoaded()) {
378
- const s = getStaticContext();
379
- console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
380
- passed++;
381
- } else {
382
- console.log(`⚠️ Loaded but empty (${ms}ms)`);
383
- failed++;
384
- }
385
- } catch (err) {
386
- console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
387
- failed++;
388
- }
570
+ // Select tests to run
571
+ const toRun = REGISTRY.filter((t) => {
572
+ if (selectedIds) return selectedIds.has(t.id);
573
+ if (t.unsafe && !includeUnsafe) return false;
574
+ return true;
575
+ });
576
+
577
+ if (toRun.length === 0) {
578
+ console.error("No matching tests.");
579
+ console.error("Run 'zhihand list' to see available tests.");
580
+ process.exit(1);
389
581
  }
390
582
 
391
- totalSteps++;
392
- process.stdout.write(" 2. Device status fields... ");
393
- {
394
- try {
395
- const status = formatDeviceStatus();
396
- const ignoredDefaults = new Set(["unknown", "0x0", "-1% (unknown)", "0"]);
397
- const fields = Object.keys(status).filter((k) => {
398
- const v = status[k];
399
- if (v === null || v === undefined) return false;
400
- if (ignoredDefaults.has(String(v))) return false;
401
- return true;
402
- });
403
- console.log(`✅ ${fields.length} fields (${fields.join(", ")})`);
404
- passed++;
405
- } catch (err) {
406
- console.log(`❌ ${err.message}`);
407
- failed++;
583
+ // Warn about missing IDs
584
+ if (selectedIds) {
585
+ const foundIds = new Set(toRun.map((t) => t.id));
586
+ const missing = [...selectedIds].filter((id) => !foundIds.has(id));
587
+ if (missing.length) {
588
+ console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
408
589
  }
409
590
  }
410
591
 
411
- await pause();
412
-
413
- // ── Phase 2: Screenshot + Basic HID ──────────────────────
414
- console.log(" ── Phase 2: Screenshot + HID ──");
415
- await runScreenshotStep("3. Screenshot");
416
- await pause();
417
- await runHidStep("4. Click center", { action: "click", xRatio: 0.5, yRatio: 0.5 });
418
- await pause();
419
- await runHidStep("5. Swipe up", { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 });
420
- await pause();
421
- await runHidStep("6. Swipe down", { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 });
422
- await pause();
423
- await runHidStep("7. Scroll down", { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 });
424
- await pause();
425
- await runHidStep("8. Scroll up", { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 });
426
- await pause();
427
-
428
- // ── Phase 3: App + Navigation ────────────────────────────
429
- console.log(" ── Phase 3: App + Navigation ──");
430
- await runHidStep("9. Press Home", { action: "home" });
431
- await pause();
432
- {
433
- const platform = isDeviceProfileLoaded() ? getStaticContext().platform : "android";
434
- const openParams = platform === "ios"
435
- ? { action: "open_app", bundleId: "com.tencent.xin" }
436
- : { action: "open_app", appPackage: "com.tencent.mm" };
437
- await runHidStep(`10. Open WeChat (${platform})`, openParams);
592
+ let currentPhase = "";
593
+ for (let i = 0; i < toRun.length; i++) {
594
+ const t = toRun[i];
595
+ if (t.phase !== currentPhase) {
596
+ console.log(` ── ${t.phase} ──`);
597
+ currentPhase = t.phase;
598
+ }
599
+ await runSingleTest(t);
600
+ if (i < toRun.length - 1) await pause();
438
601
  }
439
- await pause();
440
- await runHidStep("11. Press Back", { action: "back" });
441
- await pause();
442
-
443
- // ── Phase 4: Clipboard Set ─────────────────────────────
444
- // Note: App only supports clipboard set, not get
445
- console.log(" ── Phase 4: Clipboard ──");
446
- const clipboardTestText = `zhihand_test_${Date.now()}`;
447
- await runHidStep("12. Clipboard set", { action: "clipboard", text: clipboardTestText });
448
602
 
449
603
  // ── Summary ──────────────────────────────────────────────
450
- console.log(`\n Result: ${passed}/${totalSteps} passed`);
604
+ console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
451
605
  if (failed === 0) {
452
606
  console.log(" ✅ All tests passed! Device is fully responsive.");
453
607
  } else {
@@ -37,6 +37,11 @@ export interface WaitForCommandAckResult {
37
37
  command?: QueuedCommandRecord;
38
38
  }
39
39
  export declare function createControlCommand(params: ControlParams): QueuedControlCommand;
40
+ export interface SystemParams {
41
+ action: string;
42
+ text?: string;
43
+ }
44
+ export declare function createSystemCommand(params: SystemParams): QueuedControlCommand;
40
45
  export declare function enqueueCommand(config: ZhiHandConfig, command: QueuedControlCommand): Promise<QueuedCommandRecord>;
41
46
  export declare function getCommand(config: ZhiHandConfig, commandId: string): Promise<QueuedCommandRecord>;
42
47
  export declare function formatAckSummary(action: string, result: WaitForCommandAckResult): string;
@@ -93,6 +93,65 @@ export function createControlCommand(params) {
93
93
  throw new Error(`Unsupported action: ${params.action}`);
94
94
  }
95
95
  }
96
+ const IOS_ONLY_ACTIONS = new Set(["siri", "control_center"]);
97
+ const ANDROID_ONLY_ACTIONS = new Set(["open_browser", "shortcut_help"]);
98
+ export function createSystemCommand(params) {
99
+ const platform = isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
100
+ // Platform validation — block mismatched platform-specific actions
101
+ if (platform === "android" && IOS_ONLY_ACTIONS.has(params.action)) {
102
+ throw new Error(`Action '${params.action}' is not supported on Android.`);
103
+ }
104
+ if (platform === "ios" && ANDROID_ONLY_ACTIONS.has(params.action)) {
105
+ throw new Error(`Action '${params.action}' is not supported on iOS.`);
106
+ }
107
+ switch (params.action) {
108
+ // System navigation
109
+ case "notification":
110
+ return { type: "receive_notification", payload: {} };
111
+ case "recent":
112
+ return { type: "receive_recent", payload: {} };
113
+ case "search":
114
+ return { type: "receive_search", payload: { query: params.text ?? "" } };
115
+ case "switch_input":
116
+ return { type: "receive_switch_input", payload: {} };
117
+ case "siri":
118
+ return { type: "receive_siri", payload: {} };
119
+ case "control_center":
120
+ return { type: "receive_control_center", payload: {} };
121
+ case "open_browser":
122
+ return { type: "receive_open_browser", payload: {} };
123
+ case "shortcut_help":
124
+ return { type: "receive_shortcut_help", payload: {} };
125
+ // Media controls
126
+ case "volume_up":
127
+ return { type: "receive_volume_up", payload: {} };
128
+ case "volume_down":
129
+ return { type: "receive_volume_down", payload: {} };
130
+ case "mute":
131
+ return { type: "receive_mute", payload: {} };
132
+ case "play_pause":
133
+ return { type: "receive_play_pause", payload: {} };
134
+ case "stop":
135
+ return { type: "receive_stop", payload: {} };
136
+ case "next_track":
137
+ return { type: "receive_next_track", payload: {} };
138
+ case "prev_track":
139
+ return { type: "receive_prev_track", payload: {} };
140
+ case "fast_forward":
141
+ return { type: "receive_fast_forward", payload: {} };
142
+ case "rewind":
143
+ return { type: "receive_rewind", payload: {} };
144
+ // Hardware
145
+ case "brightness_up":
146
+ return { type: "receive_brightness_up", payload: {} };
147
+ case "brightness_down":
148
+ return { type: "receive_brightness_down", payload: {} };
149
+ case "power":
150
+ return { type: "receive_power", payload: {} };
151
+ default:
152
+ throw new Error(`Unsupported system action: ${params.action}`);
153
+ }
154
+ }
96
155
  export async function enqueueCommand(config, command) {
97
156
  const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands`;
98
157
  const body = { command: { ...command, message_id: command.messageId ?? nextMessageId() } };
@@ -43,5 +43,6 @@ export declare function extractDynamic(profile: Record<string, unknown>): Dynami
43
43
  export declare function updateDeviceProfile(raw: Record<string, unknown>): void;
44
44
  export declare function fetchDeviceProfile(config: ZhiHandConfig): Promise<void>;
45
45
  export declare function buildControlToolDescription(): string;
46
+ export declare function buildSystemToolDescription(): string;
46
47
  export declare function buildScreenshotToolDescription(): string;
47
48
  export declare function formatDeviceStatus(): Record<string, unknown>;
@@ -176,6 +176,28 @@ export function buildControlToolDescription() {
176
176
  }
177
177
  return desc;
178
178
  }
179
+ export function buildSystemToolDescription() {
180
+ if (!loaded || staticCtx.platform === "unknown") {
181
+ return "System navigation and media controls. Actions: notification, recent, search, switch_input, siri (iOS), control_center (iOS), open_browser (Android), shortcut_help (Android), volume_up/down, mute, play_pause, stop, next/prev_track, fast_forward, rewind, brightness_up/down, power.";
182
+ }
183
+ const platform = staticCtx.platform;
184
+ const parts = [
185
+ `System navigation and media controls for ${platform} device (${staticCtx.model}).`,
186
+ ];
187
+ // Navigation
188
+ parts.push("Navigation: notification, recent, search (optional text query), switch_input.");
189
+ if (platform === "ios") {
190
+ parts.push("iOS: siri, control_center.");
191
+ }
192
+ else if (platform === "android") {
193
+ parts.push("Android: open_browser, shortcut_help.");
194
+ }
195
+ // Media
196
+ parts.push("Media: volume_up, volume_down, mute, play_pause, stop, next_track, prev_track, fast_forward, rewind.");
197
+ // Hardware
198
+ parts.push("Hardware: brightness_up, brightness_down, power.");
199
+ return parts.join(" ");
200
+ }
179
201
  export function buildScreenshotToolDescription() {
180
202
  if (!loaded || staticCtx.platform === "unknown") {
181
203
  return "Take a screenshot of the phone screen.";
@@ -372,6 +372,17 @@ function buildSystemContext() {
372
372
  else {
373
373
  openAppDoc = "- open_app: Open an app. Params: appPackage (Android, e.g. 'com.tencent.mm'), bundleId (iOS), urlScheme (e.g. 'weixin://')";
374
374
  }
375
+ // Platform-specific system actions
376
+ let platformSystemDoc;
377
+ if (static_?.platform === "ios") {
378
+ platformSystemDoc = "- siri: Activate Siri\n- control_center: Open Control Center";
379
+ }
380
+ else if (static_?.platform === "android") {
381
+ platformSystemDoc = "- open_browser: Launch default browser\n- shortcut_help: Show keyboard shortcuts overlay";
382
+ }
383
+ else {
384
+ platformSystemDoc = "- siri: Activate Siri (iOS only)\n- control_center: Open Control Center (iOS only)\n- open_browser: Launch default browser (Android only)\n- shortcut_help: Show keyboard shortcuts overlay (Android only)";
385
+ }
375
386
  return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
376
387
 
377
388
  ## Device
@@ -401,13 +412,35 @@ ${openAppDoc}
401
412
  - screenshot: Capture screen via control (same as zhihand_screenshot)
402
413
  - wait: Wait before next action. Params: durationMs (default 1000)
403
414
 
415
+ ### zhihand_system
416
+ System navigation and media controls. Requires "action" parameter.
417
+
418
+ **System navigation:**
419
+ - notification: Open notification shade/center
420
+ - recent: Show app switcher / recent apps
421
+ - search: Open system search. Optional "text" param to type query after opening
422
+ - switch_input: Switch input method (only works in text input fields)
423
+ ${platformSystemDoc}
424
+
425
+ **Media controls:**
426
+ - volume_up / volume_down: Adjust volume
427
+ - mute: Toggle mute
428
+ - play_pause / stop: Playback control
429
+ - next_track / prev_track: Skip track
430
+ - fast_forward / rewind: Seek
431
+
432
+ **Hardware:**
433
+ - brightness_up / brightness_down: Adjust brightness
434
+ - power: Press power button
435
+
404
436
  ### zhihand_status
405
437
  Get device status: platform, battery, network, BLE connection, dark mode, storage, etc.
406
438
 
407
439
  ## Rules
408
440
  - When the user asks to see their screen, ALWAYS call zhihand_screenshot first.
409
- - When the user asks to open an app (e.g. WeChat, Settings), use open_app action.
410
- - When the user asks to go back/home, use back/home actions.
441
+ - When the user asks to open an app (e.g. WeChat, Settings), use open_app action with zhihand_control.
442
+ - When the user asks to go back/home, use back/home actions with zhihand_control.
443
+ - For system functions (notifications, volume, brightness, media), use zhihand_system.
411
444
  - For all tap/click operations, use xRatio and yRatio (0-1 normalized coordinates based on the screenshot).`;
412
445
  }
413
446
  /**
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.26.4";
2
+ export declare const PACKAGE_VERSION = "0.28.0";
3
3
  export declare function createServer(deviceName?: string): McpServer;
4
4
  export declare function startStdioServer(deviceName?: string): Promise<void>;
package/dist/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { resolveConfig } from "./core/config.js";
4
- import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
4
+ import { controlSchema, systemSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
5
5
  import { executeControl } from "./tools/control.js";
6
+ import { executeSystem } from "./tools/system.js";
6
7
  import { handleScreenshot } from "./tools/screenshot.js";
7
8
  import { handlePair } from "./tools/pair.js";
8
- import { getStaticContext, getDynamicContext, fetchDeviceProfile, buildControlToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
9
- export const PACKAGE_VERSION = "0.26.4";
9
+ import { getStaticContext, getDynamicContext, fetchDeviceProfile, buildControlToolDescription, buildSystemToolDescription, buildScreenshotToolDescription, formatDeviceStatus, } from "./core/device.js";
10
+ export const PACKAGE_VERSION = "0.28.0";
10
11
  export function createServer(deviceName) {
11
12
  const server = new McpServer({
12
13
  name: "zhihand",
@@ -18,6 +19,11 @@ export function createServer(deviceName) {
18
19
  const config = resolveConfig(deviceName);
19
20
  return await executeControl(config, params);
20
21
  });
22
+ // zhihand_system — system navigation + media controls (separate tool per Gemini design review)
23
+ server.tool("zhihand_system", buildSystemToolDescription(), systemSchema, async (params) => {
24
+ const config = resolveConfig(deviceName);
25
+ return await executeSystem(config, params);
26
+ });
21
27
  // zhihand_screenshot — capture current screen without any action
22
28
  server.tool("zhihand_screenshot", buildScreenshotToolDescription(), screenshotSchema, async () => {
23
29
  const config = resolveConfig(deviceName);
@@ -16,6 +16,10 @@ export declare const controlSchema: {
16
16
  bundleId: z.ZodOptional<z.ZodString>;
17
17
  urlScheme: z.ZodOptional<z.ZodString>;
18
18
  };
19
+ export declare const systemSchema: {
20
+ 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
+ text: z.ZodOptional<z.ZodString>;
22
+ };
19
23
  export declare const screenshotSchema: {};
20
24
  export declare const pairSchema: {
21
25
  forceNew: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
@@ -23,6 +23,24 @@ export const controlSchema = {
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
25
  };
26
+ // zhihand_system — system navigation + media controls (separate from UI control)
27
+ export const systemSchema = {
28
+ action: z.enum([
29
+ // System navigation — cross-platform
30
+ "notification", "recent", "search", "switch_input",
31
+ // System navigation — iOS only
32
+ "siri", "control_center",
33
+ // System navigation — Android only
34
+ "open_browser", "shortcut_help",
35
+ // Media controls — cross-platform
36
+ "volume_up", "volume_down", "mute",
37
+ "play_pause", "stop", "next_track", "prev_track",
38
+ "fast_forward", "rewind",
39
+ // Hardware — cross-platform
40
+ "brightness_up", "brightness_down", "power",
41
+ ]).describe("System or media action to perform"),
42
+ text: z.string().optional().describe("Optional text, e.g. search query for 'search' action"),
43
+ };
26
44
  export const screenshotSchema = {};
27
45
  export const pairSchema = {
28
46
  forceNew: z.boolean().default(false).optional().describe("Force new pairing even if already paired"),
@@ -0,0 +1,17 @@
1
+ /**
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
+ */
7
+ import type { ZhiHandConfig } from "../core/config.ts";
8
+ import type { SystemParams } from "../core/command.ts";
9
+ type TextContent = {
10
+ type: "text";
11
+ text: string;
12
+ };
13
+ type ToolResult = {
14
+ content: TextContent[];
15
+ };
16
+ export declare function executeSystem(config: ZhiHandConfig, params: SystemParams): Promise<ToolResult>;
17
+ export {};
@@ -0,0 +1,11 @@
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);
5
+ const queued = await enqueueCommand(config, command);
6
+ const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 15_000 });
7
+ const summary = formatAckSummary(params.action, ack);
8
+ return {
9
+ content: [{ type: "text", text: summary }],
10
+ };
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.26.4",
3
+ "version": "0.28.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",