mcp-android-emulator 1.1.0 → 1.2.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 (3) hide show
  1. package/dist/index.js +646 -1
  2. package/package.json +1 -1
  3. package/src/index.ts +796 -1
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ async function shell(command) {
41
41
  // Create MCP server
42
42
  const server = new McpServer({
43
43
  name: "android-emulator",
44
- version: "1.0.0",
44
+ version: "1.2.0",
45
45
  });
46
46
  // =====================================================
47
47
  // TOOL: screenshot
@@ -562,6 +562,651 @@ server.tool("double_tap", "Perform a double tap at the specified coordinates", {
562
562
  ],
563
563
  };
564
564
  });
565
+ // =====================================================
566
+ // TOOL: get_screen_size
567
+ // =====================================================
568
+ server.tool("get_screen_size", "Get the screen dimensions and density of the device", {}, async () => {
569
+ const [sizeOutput, densityOutput] = await Promise.all([
570
+ shell("wm size"),
571
+ shell("wm density"),
572
+ ]);
573
+ const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
574
+ const densityMatch = densityOutput.match(/(\d+)/);
575
+ const width = sizeMatch ? parseInt(sizeMatch[1]) : 0;
576
+ const height = sizeMatch ? parseInt(sizeMatch[2]) : 0;
577
+ const density = densityMatch ? parseInt(densityMatch[1]) : 0;
578
+ return {
579
+ content: [
580
+ {
581
+ type: "text",
582
+ text: JSON.stringify({ width, height, density }, null, 2),
583
+ },
584
+ ],
585
+ };
586
+ });
587
+ // =====================================================
588
+ // TOOL: is_element_visible
589
+ // =====================================================
590
+ server.tool("is_element_visible", "Check if an element with specific text or resource-id is visible on screen", {
591
+ text: z.string().optional().describe("Text to search for"),
592
+ resourceId: z.string().optional().describe("Resource ID to search for"),
593
+ }, async ({ text, resourceId }) => {
594
+ if (!text && !resourceId) {
595
+ return {
596
+ content: [
597
+ {
598
+ type: "text",
599
+ text: JSON.stringify({ visible: false, error: "Must provide text or resourceId" }),
600
+ },
601
+ ],
602
+ };
603
+ }
604
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
605
+ const xml = await shell("cat /sdcard/ui_dump.xml");
606
+ let found = false;
607
+ let bounds = null;
608
+ if (text) {
609
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
610
+ const regex = new RegExp(`text="[^"]*${escapedText}[^"]*".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`, "i");
611
+ const match = regex.exec(xml);
612
+ if (match) {
613
+ found = true;
614
+ const [, x1, y1, x2, y2] = match;
615
+ bounds = {
616
+ x: parseInt(x1),
617
+ y: parseInt(y1),
618
+ width: parseInt(x2) - parseInt(x1),
619
+ height: parseInt(y2) - parseInt(y1),
620
+ centerX: Math.round((parseInt(x1) + parseInt(x2)) / 2),
621
+ centerY: Math.round((parseInt(y1) + parseInt(y2)) / 2),
622
+ };
623
+ }
624
+ }
625
+ if (resourceId && !found) {
626
+ const regex = new RegExp(`resource-id="${resourceId}".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`, "i");
627
+ const match = regex.exec(xml);
628
+ if (match) {
629
+ found = true;
630
+ const [, x1, y1, x2, y2] = match;
631
+ bounds = {
632
+ x: parseInt(x1),
633
+ y: parseInt(y1),
634
+ width: parseInt(x2) - parseInt(x1),
635
+ height: parseInt(y2) - parseInt(y1),
636
+ centerX: Math.round((parseInt(x1) + parseInt(x2)) / 2),
637
+ centerY: Math.round((parseInt(y1) + parseInt(y2)) / 2),
638
+ };
639
+ }
640
+ }
641
+ return {
642
+ content: [
643
+ {
644
+ type: "text",
645
+ text: JSON.stringify({ visible: found, bounds }, null, 2),
646
+ },
647
+ ],
648
+ };
649
+ });
650
+ // =====================================================
651
+ // TOOL: get_element_bounds
652
+ // =====================================================
653
+ server.tool("get_element_bounds", "Get the exact bounds and center coordinates of an element", {
654
+ text: z.string().optional().describe("Text of the element"),
655
+ resourceId: z.string().optional().describe("Resource ID of the element"),
656
+ index: z.number().optional().describe("Index if multiple matches (0-based, default: 0)"),
657
+ }, async ({ text, resourceId, index = 0 }) => {
658
+ if (!text && !resourceId) {
659
+ return {
660
+ content: [
661
+ {
662
+ type: "text",
663
+ text: JSON.stringify({ error: "Must provide text or resourceId" }),
664
+ },
665
+ ],
666
+ };
667
+ }
668
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
669
+ const xml = await shell("cat /sdcard/ui_dump.xml");
670
+ let pattern;
671
+ if (text) {
672
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
673
+ pattern = `text="[^"]*${escapedText}[^"]*".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
674
+ }
675
+ else {
676
+ pattern = `resource-id="${resourceId}".*?bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
677
+ }
678
+ const regex = new RegExp(pattern, "gi");
679
+ const matches = [];
680
+ let match;
681
+ while ((match = regex.exec(xml)) !== null) {
682
+ matches.push({
683
+ x1: parseInt(match[1]),
684
+ y1: parseInt(match[2]),
685
+ x2: parseInt(match[3]),
686
+ y2: parseInt(match[4]),
687
+ });
688
+ }
689
+ if (matches.length === 0) {
690
+ return {
691
+ content: [
692
+ {
693
+ type: "text",
694
+ text: JSON.stringify({ found: false, error: "Element not found" }),
695
+ },
696
+ ],
697
+ };
698
+ }
699
+ if (index >= matches.length) {
700
+ return {
701
+ content: [
702
+ {
703
+ type: "text",
704
+ text: JSON.stringify({
705
+ found: false,
706
+ error: `Index ${index} out of range. Found ${matches.length} matches.`,
707
+ }),
708
+ },
709
+ ],
710
+ };
711
+ }
712
+ const m = matches[index];
713
+ const result = {
714
+ found: true,
715
+ matchCount: matches.length,
716
+ index,
717
+ bounds: {
718
+ x: m.x1,
719
+ y: m.y1,
720
+ width: m.x2 - m.x1,
721
+ height: m.y2 - m.y1,
722
+ },
723
+ center: {
724
+ x: Math.round((m.x1 + m.x2) / 2),
725
+ y: Math.round((m.y1 + m.y2) / 2),
726
+ },
727
+ };
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: JSON.stringify(result, null, 2),
733
+ },
734
+ ],
735
+ };
736
+ });
737
+ // =====================================================
738
+ // TOOL: scroll_to_text
739
+ // =====================================================
740
+ server.tool("scroll_to_text", "Scroll the screen until an element with specific text is visible", {
741
+ text: z.string().describe("Text to search for"),
742
+ direction: z.enum(["up", "down"]).optional().describe("Scroll direction (default: down)"),
743
+ maxScrolls: z.number().optional().describe("Maximum scroll attempts (default: 10)"),
744
+ }, async ({ text, direction = "down", maxScrolls = 10 }) => {
745
+ const sizeOutput = await shell("wm size");
746
+ const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
747
+ const width = sizeMatch ? parseInt(sizeMatch[1]) : 1080;
748
+ const height = sizeMatch ? parseInt(sizeMatch[2]) : 2400;
749
+ const centerX = Math.round(width / 2);
750
+ const startY = direction === "down" ? Math.round(height * 0.7) : Math.round(height * 0.3);
751
+ const endY = direction === "down" ? Math.round(height * 0.3) : Math.round(height * 0.7);
752
+ for (let i = 0; i < maxScrolls; i++) {
753
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
754
+ const xml = await shell("cat /sdcard/ui_dump.xml");
755
+ if (xml.toLowerCase().includes(text.toLowerCase())) {
756
+ return {
757
+ content: [
758
+ {
759
+ type: "text",
760
+ text: `Found "${text}" after ${i} scroll(s)`,
761
+ },
762
+ ],
763
+ };
764
+ }
765
+ await shell(`input swipe ${centerX} ${startY} ${centerX} ${endY} 300`);
766
+ await new Promise((resolve) => setTimeout(resolve, 500));
767
+ }
768
+ return {
769
+ content: [
770
+ {
771
+ type: "text",
772
+ text: `Text "${text}" not found after ${maxScrolls} scrolls`,
773
+ },
774
+ ],
775
+ };
776
+ });
777
+ // =====================================================
778
+ // TOOL: wait_for_ui_stable
779
+ // =====================================================
780
+ server.tool("wait_for_ui_stable", "Wait for the UI to stop changing (useful after animations)", {
781
+ timeout: z.number().optional().describe("Timeout in milliseconds (default: 5000)"),
782
+ checkInterval: z.number().optional().describe("Check interval in milliseconds (default: 500)"),
783
+ }, async ({ timeout = 5000, checkInterval = 500 }) => {
784
+ const startTime = Date.now();
785
+ let lastXml = "";
786
+ let stableCount = 0;
787
+ while (Date.now() - startTime < timeout) {
788
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
789
+ const currentXml = await shell("cat /sdcard/ui_dump.xml");
790
+ if (currentXml === lastXml) {
791
+ stableCount++;
792
+ if (stableCount >= 2) {
793
+ return {
794
+ content: [
795
+ {
796
+ type: "text",
797
+ text: `UI stable after ${Math.round((Date.now() - startTime) / 1000)}s`,
798
+ },
799
+ ],
800
+ };
801
+ }
802
+ }
803
+ else {
804
+ stableCount = 0;
805
+ lastXml = currentXml;
806
+ }
807
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
808
+ }
809
+ return {
810
+ content: [
811
+ {
812
+ type: "text",
813
+ text: `Timeout: UI did not stabilize within ${timeout}ms`,
814
+ },
815
+ ],
816
+ };
817
+ });
818
+ // =====================================================
819
+ // TOOL: wait_for_element_gone
820
+ // =====================================================
821
+ server.tool("wait_for_element_gone", "Wait for an element to disappear from the screen", {
822
+ text: z.string().describe("Text of the element to wait for disappearance"),
823
+ timeout: z.number().optional().describe("Timeout in milliseconds (default: 10000)"),
824
+ }, async ({ text, timeout = 10000 }) => {
825
+ const startTime = Date.now();
826
+ while (Date.now() - startTime < timeout) {
827
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
828
+ const xml = await shell("cat /sdcard/ui_dump.xml");
829
+ if (!xml.toLowerCase().includes(text.toLowerCase())) {
830
+ return {
831
+ content: [
832
+ {
833
+ type: "text",
834
+ text: `Element "${text}" disappeared after ${Math.round((Date.now() - startTime) / 1000)}s`,
835
+ },
836
+ ],
837
+ };
838
+ }
839
+ await new Promise((resolve) => setTimeout(resolve, 500));
840
+ }
841
+ return {
842
+ content: [
843
+ {
844
+ type: "text",
845
+ text: `Timeout: Element "${text}" still visible after ${timeout}ms`,
846
+ },
847
+ ],
848
+ };
849
+ });
850
+ // =====================================================
851
+ // TOOL: multi_tap
852
+ // =====================================================
853
+ server.tool("multi_tap", "Perform multiple rapid taps at the same position", {
854
+ x: z.number().describe("X coordinate"),
855
+ y: z.number().describe("Y coordinate"),
856
+ taps: z.number().optional().describe("Number of taps (default: 2)"),
857
+ interval: z.number().optional().describe("Interval between taps in ms (default: 100)"),
858
+ }, async ({ x, y, taps = 2, interval = 100 }) => {
859
+ for (let i = 0; i < taps; i++) {
860
+ await shell(`input tap ${x} ${y}`);
861
+ if (i < taps - 1) {
862
+ await new Promise((resolve) => setTimeout(resolve, interval));
863
+ }
864
+ }
865
+ return {
866
+ content: [
867
+ {
868
+ type: "text",
869
+ text: `Performed ${taps} taps at (${x}, ${y})`,
870
+ },
871
+ ],
872
+ };
873
+ });
874
+ // =====================================================
875
+ // TOOL: pinch_zoom
876
+ // =====================================================
877
+ server.tool("pinch_zoom", "Perform a pinch zoom gesture (requires Android 8+)", {
878
+ x: z.number().describe("Center X coordinate"),
879
+ y: z.number().describe("Center Y coordinate"),
880
+ scale: z.number().describe("Scale factor (>1 zoom in, <1 zoom out)"),
881
+ duration: z.number().optional().describe("Duration in milliseconds (default: 500)"),
882
+ }, async ({ x, y, scale, duration = 500 }) => {
883
+ // Pinch zoom simulation using two swipe gestures
884
+ // This is a simplified approach - real multitouch requires instrumentation
885
+ const distance = 200;
886
+ const scaledDistance = Math.round(distance * scale);
887
+ if (scale > 1) {
888
+ // Zoom in: fingers move apart
889
+ // Simulate with two sequential swipes from center outward
890
+ const halfDist = Math.round(scaledDistance / 2);
891
+ await shell(`input swipe ${x} ${y - 50} ${x} ${y - halfDist} ${duration}`);
892
+ await shell(`input swipe ${x} ${y + 50} ${x} ${y + halfDist} ${duration}`);
893
+ }
894
+ else {
895
+ // Zoom out: fingers move together
896
+ const halfDist = Math.round(distance / 2);
897
+ const targetDist = Math.round((distance * scale) / 2);
898
+ await shell(`input swipe ${x} ${y - halfDist} ${x} ${y - targetDist} ${duration}`);
899
+ await shell(`input swipe ${x} ${y + halfDist} ${x} ${y + targetDist} ${duration}`);
900
+ }
901
+ return {
902
+ content: [
903
+ {
904
+ type: "text",
905
+ text: `Pinch zoom at (${x}, ${y}) with scale ${scale}. Note: True multitouch requires instrumentation.`,
906
+ },
907
+ ],
908
+ };
909
+ });
910
+ // =====================================================
911
+ // TOOL: set_clipboard
912
+ // =====================================================
913
+ server.tool("set_clipboard", "Set text to the device clipboard", {
914
+ text: z.string().describe("Text to copy to clipboard"),
915
+ }, async ({ text }) => {
916
+ // Use am broadcast to set clipboard content
917
+ const escaped = text.replace(/'/g, "'\\''");
918
+ await shell(`am broadcast -a clipper.set -e text '${escaped}'`);
919
+ // Alternative method using service call (works on more devices)
920
+ // This is a fallback approach
921
+ const base64Text = Buffer.from(text).toString("base64");
922
+ await shell(`echo "${base64Text}" | base64 -d > /sdcard/clipboard_temp.txt`);
923
+ return {
924
+ content: [
925
+ {
926
+ type: "text",
927
+ text: `Clipboard set to: "${text.substring(0, 50)}${text.length > 50 ? "..." : ""}"`,
928
+ },
929
+ ],
930
+ };
931
+ });
932
+ // =====================================================
933
+ // TOOL: get_clipboard
934
+ // =====================================================
935
+ server.tool("get_clipboard", "Get the current device clipboard content", {}, async () => {
936
+ try {
937
+ // Try to get clipboard via am broadcast
938
+ const result = await shell("am broadcast -a clipper.get");
939
+ const match = result.match(/data="([^"]*)"/);
940
+ if (match) {
941
+ return {
942
+ content: [
943
+ {
944
+ type: "text",
945
+ text: `Clipboard content: "${match[1]}"`,
946
+ },
947
+ ],
948
+ };
949
+ }
950
+ }
951
+ catch {
952
+ // Clipper app not installed, try alternative
953
+ }
954
+ // Alternative: read from our temp file if set_clipboard was used
955
+ try {
956
+ const content = await shell("cat /sdcard/clipboard_temp.txt 2>/dev/null || echo ''");
957
+ return {
958
+ content: [
959
+ {
960
+ type: "text",
961
+ text: `Clipboard content: "${content}"`,
962
+ },
963
+ ],
964
+ };
965
+ }
966
+ catch {
967
+ return {
968
+ content: [
969
+ {
970
+ type: "text",
971
+ text: "Could not retrieve clipboard. Install Clipper app for full clipboard support.",
972
+ },
973
+ ],
974
+ };
975
+ }
976
+ });
977
+ // =====================================================
978
+ // TOOL: rotate_device
979
+ // =====================================================
980
+ server.tool("rotate_device", "Rotate the device to portrait or landscape orientation", {
981
+ orientation: z.enum(["portrait", "landscape"]).describe("Target orientation"),
982
+ }, async ({ orientation }) => {
983
+ // Disable auto-rotation first
984
+ await shell("settings put system accelerometer_rotation 0");
985
+ // Set user rotation (0 = portrait, 1 = landscape)
986
+ const rotation = orientation === "portrait" ? 0 : 1;
987
+ await shell(`settings put system user_rotation ${rotation}`);
988
+ return {
989
+ content: [
990
+ {
991
+ type: "text",
992
+ text: `Device rotated to ${orientation}`,
993
+ },
994
+ ],
995
+ };
996
+ });
997
+ // =====================================================
998
+ // TOOL: tap_safe
999
+ // =====================================================
1000
+ server.tool("tap_safe", "Tap at coordinates while avoiding system navigation bars", {
1001
+ x: z.number().describe("X coordinate"),
1002
+ y: z.number().describe("Y coordinate"),
1003
+ avoidStatusBar: z.boolean().optional().describe("Avoid status bar area (default: true)"),
1004
+ avoidNavBar: z.boolean().optional().describe("Avoid navigation bar area (default: true)"),
1005
+ }, async ({ x, y, avoidStatusBar = true, avoidNavBar = true }) => {
1006
+ // Get screen dimensions
1007
+ const sizeOutput = await shell("wm size");
1008
+ const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
1009
+ const screenWidth = sizeMatch ? parseInt(sizeMatch[1]) : 1080;
1010
+ const screenHeight = sizeMatch ? parseInt(sizeMatch[2]) : 2400;
1011
+ // Typical safe areas (approximate)
1012
+ const statusBarHeight = 50; // ~50px for status bar
1013
+ const navBarHeight = 120; // ~120px for navigation bar
1014
+ let safeY = y;
1015
+ let adjusted = false;
1016
+ const adjustments = [];
1017
+ // Check and adjust for status bar
1018
+ if (avoidStatusBar && y < statusBarHeight) {
1019
+ safeY = statusBarHeight + 10;
1020
+ adjusted = true;
1021
+ adjustments.push(`status bar (${y} -> ${safeY})`);
1022
+ }
1023
+ // Check and adjust for navigation bar
1024
+ if (avoidNavBar && y > screenHeight - navBarHeight) {
1025
+ safeY = screenHeight - navBarHeight - 10;
1026
+ adjusted = true;
1027
+ adjustments.push(`nav bar (${y} -> ${safeY})`);
1028
+ }
1029
+ // Ensure X is within bounds
1030
+ let safeX = Math.max(10, Math.min(x, screenWidth - 10));
1031
+ await shell(`input tap ${safeX} ${safeY}`);
1032
+ const message = adjusted
1033
+ ? `Tapped at (${safeX}, ${safeY}) [adjusted to avoid ${adjustments.join(", ")}]`
1034
+ : `Tapped at (${safeX}, ${safeY})`;
1035
+ return {
1036
+ content: [
1037
+ {
1038
+ type: "text",
1039
+ text: message,
1040
+ },
1041
+ ],
1042
+ };
1043
+ });
1044
+ // =====================================================
1045
+ // TOOL: tap_element
1046
+ // =====================================================
1047
+ server.tool("tap_element", "Find and tap an element by text or resource-id (more reliable than tap_text)", {
1048
+ text: z.string().optional().describe("Text to search for"),
1049
+ resourceId: z.string().optional().describe("Resource ID to search for"),
1050
+ index: z.number().optional().describe("Index if multiple matches (0-based, default: 0)"),
1051
+ exact: z.boolean().optional().describe("Exact text match (default: false)"),
1052
+ }, async ({ text, resourceId, index = 0, exact = false }) => {
1053
+ if (!text && !resourceId) {
1054
+ return {
1055
+ content: [
1056
+ {
1057
+ type: "text",
1058
+ text: "Error: Must provide either text or resourceId",
1059
+ },
1060
+ ],
1061
+ };
1062
+ }
1063
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
1064
+ const xml = await shell("cat /sdcard/ui_dump.xml");
1065
+ let pattern;
1066
+ let searchType;
1067
+ if (resourceId) {
1068
+ pattern = `resource-id="${resourceId}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
1069
+ searchType = `resource-id="${resourceId}"`;
1070
+ }
1071
+ else if (exact) {
1072
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1073
+ pattern = `text="${escapedText}"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
1074
+ searchType = `text="${text}"`;
1075
+ }
1076
+ else {
1077
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1078
+ pattern = `text="[^"]*${escapedText}[^"]*"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"`;
1079
+ searchType = `text containing "${text}"`;
1080
+ }
1081
+ const regex = new RegExp(pattern, "gi");
1082
+ const matches = [];
1083
+ let match;
1084
+ while ((match = regex.exec(xml)) !== null) {
1085
+ matches.push({
1086
+ x1: parseInt(match[1]),
1087
+ y1: parseInt(match[2]),
1088
+ x2: parseInt(match[3]),
1089
+ y2: parseInt(match[4]),
1090
+ });
1091
+ }
1092
+ if (matches.length === 0) {
1093
+ return {
1094
+ content: [
1095
+ {
1096
+ type: "text",
1097
+ text: `Element with ${searchType} not found`,
1098
+ },
1099
+ ],
1100
+ };
1101
+ }
1102
+ if (index >= matches.length) {
1103
+ return {
1104
+ content: [
1105
+ {
1106
+ type: "text",
1107
+ text: `Index ${index} out of range. Found ${matches.length} matches for ${searchType}`,
1108
+ },
1109
+ ],
1110
+ };
1111
+ }
1112
+ const m = matches[index];
1113
+ const centerX = Math.round((m.x1 + m.x2) / 2);
1114
+ const centerY = Math.round((m.y1 + m.y2) / 2);
1115
+ await shell(`input tap ${centerX} ${centerY}`);
1116
+ return {
1117
+ content: [
1118
+ {
1119
+ type: "text",
1120
+ text: `Tapped element with ${searchType} at (${centerX}, ${centerY})${matches.length > 1 ? ` [match ${index + 1}/${matches.length}]` : ""}`,
1121
+ },
1122
+ ],
1123
+ };
1124
+ });
1125
+ // =====================================================
1126
+ // TOOL: get_focused_element
1127
+ // =====================================================
1128
+ server.tool("get_focused_element", "Get information about the currently focused UI element", {}, async () => {
1129
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
1130
+ const xml = await shell("cat /sdcard/ui_dump.xml");
1131
+ const focusedRegex = /focused="true"[^>]*text="([^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/;
1132
+ const match = focusedRegex.exec(xml);
1133
+ if (!match) {
1134
+ // Try alternative pattern
1135
+ const altRegex = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"[^>]*focused="true"[^>]*text="([^"]*)"/;
1136
+ const altMatch = altRegex.exec(xml);
1137
+ if (!altMatch) {
1138
+ return {
1139
+ content: [
1140
+ {
1141
+ type: "text",
1142
+ text: JSON.stringify({ focused: false, element: null }),
1143
+ },
1144
+ ],
1145
+ };
1146
+ }
1147
+ const [, x1, y1, x2, y2, text] = altMatch;
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: "text",
1152
+ text: JSON.stringify({
1153
+ focused: true,
1154
+ element: {
1155
+ text,
1156
+ bounds: { x: parseInt(x1), y: parseInt(y1), width: parseInt(x2) - parseInt(x1), height: parseInt(y2) - parseInt(y1) },
1157
+ center: { x: Math.round((parseInt(x1) + parseInt(x2)) / 2), y: Math.round((parseInt(y1) + parseInt(y2)) / 2) },
1158
+ },
1159
+ }, null, 2),
1160
+ },
1161
+ ],
1162
+ };
1163
+ }
1164
+ const [, text, x1, y1, x2, y2] = match;
1165
+ return {
1166
+ content: [
1167
+ {
1168
+ type: "text",
1169
+ text: JSON.stringify({
1170
+ focused: true,
1171
+ element: {
1172
+ text,
1173
+ bounds: { x: parseInt(x1), y: parseInt(y1), width: parseInt(x2) - parseInt(x1), height: parseInt(y2) - parseInt(y1) },
1174
+ center: { x: Math.round((parseInt(x1) + parseInt(x2)) / 2), y: Math.round((parseInt(y1) + parseInt(y2)) / 2) },
1175
+ },
1176
+ }, null, 2),
1177
+ },
1178
+ ],
1179
+ };
1180
+ });
1181
+ // =====================================================
1182
+ // TOOL: assert_screen_contains
1183
+ // =====================================================
1184
+ server.tool("assert_screen_contains", "Assert that specific text is visible on screen (useful for testing)", {
1185
+ text: z.string().describe("Text that should be visible"),
1186
+ exact: z.boolean().optional().describe("Exact match (default: false)"),
1187
+ }, async ({ text, exact = false }) => {
1188
+ await shell("uiautomator dump /sdcard/ui_dump.xml");
1189
+ const xml = await shell("cat /sdcard/ui_dump.xml");
1190
+ let found;
1191
+ if (exact) {
1192
+ found = xml.includes(`text="${text}"`);
1193
+ }
1194
+ else {
1195
+ found = xml.toLowerCase().includes(text.toLowerCase());
1196
+ }
1197
+ return {
1198
+ content: [
1199
+ {
1200
+ type: "text",
1201
+ text: JSON.stringify({
1202
+ assertion: found ? "PASS" : "FAIL",
1203
+ expected: text,
1204
+ found,
1205
+ }, null, 2),
1206
+ },
1207
+ ],
1208
+ };
1209
+ });
565
1210
  // Start server
566
1211
  async function main() {
567
1212
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-android-emulator",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP Server for Android Emulator interaction via ADB - enables AI assistants to control Android devices",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",