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.
- package/dist/index.js +646 -1
- package/package.json +1 -1
- 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.
|
|
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