Skip to content

Commit 975f141

Browse files
xaionaro@dx.centerxaionaro@dx.center
authored andcommitted
feat: binder-mcp P0 shell tools — screenshot, input, UI, apps, settings, shell
1 parent cef65da commit 975f141

25 files changed

Lines changed: 1658 additions & 0 deletions

cmd/binder-mcp/device.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ func runDevice(cmd *cobra.Command, _ []string) error {
7171
)
7272

7373
tools.Register(mcpServer)
74+
RegisterShellTools(mcpServer)
7475

7576
logger.Debugf(ctx, "serving MCP over stdio")
7677

cmd/binder-mcp/remote.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func runRemoteMode(
9292
)
9393

9494
tools.Register(mcpServer)
95+
RegisterShellTools(mcpServer)
9596

9697
logger.Debugf(ctx, "serving MCP over stdio (remote mode)")
9798

cmd/binder-mcp/shell.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os/exec"
8+
"strings"
9+
)
10+
11+
// shellExec runs a shell command and returns the combined stdout+stderr output.
12+
func shellExec(command string) (string, error) {
13+
cmd := exec.Command("sh", "-c", command)
14+
out, err := cmd.CombinedOutput()
15+
result := strings.TrimSpace(string(out))
16+
if err != nil {
17+
return result, fmt.Errorf("command %q: %w (output: %s)", command, err, result)
18+
}
19+
return result, nil
20+
}
21+
22+
// shellQuote quotes a string for safe use in shell commands,
23+
// preventing injection attacks.
24+
func shellQuote(s string) string {
25+
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
26+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
10+
"github.com/facebookincubator/go-belt/tool/logger"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
)
14+
15+
// ClickUIResult describes the click_ui_element response.
16+
type ClickUIResult struct {
17+
MatchedText string `json:"matched_text,omitempty"`
18+
MatchedResourceID string `json:"matched_resource_id,omitempty"`
19+
MatchedContentDesc string `json:"matched_content_desc,omitempty"`
20+
TappedX int `json:"tapped_x"`
21+
TappedY int `json:"tapped_y"`
22+
}
23+
24+
func registerClickUIElement(s *server.MCPServer) {
25+
tool := mcp.NewTool("click_ui_element",
26+
mcp.WithDescription(
27+
"Find a UI element by text, resource-id, or content-desc, "+
28+
"compute the center of its bounds, and tap it. "+
29+
"This is a convenience tool that combines find_ui_element + tap. "+
30+
"Returns an error if no matching element is found or if multiple "+
31+
"elements match (use more specific criteria).",
32+
),
33+
mcp.WithString("text",
34+
mcp.Description("Match element whose text contains this substring (case-insensitive)"),
35+
),
36+
mcp.WithString("resource_id",
37+
mcp.Description("Match element whose resource-id contains this substring"),
38+
),
39+
mcp.WithString("content_desc",
40+
mcp.Description("Match element whose content-desc contains this substring (case-insensitive)"),
41+
),
42+
mcp.WithDestructiveHintAnnotation(true),
43+
mcp.WithIdempotentHintAnnotation(false),
44+
)
45+
46+
s.AddTool(tool, handleClickUIElement)
47+
}
48+
49+
func handleClickUIElement(
50+
ctx context.Context,
51+
request mcp.CallToolRequest,
52+
) (*mcp.CallToolResult, error) {
53+
logger.Tracef(ctx, "handleClickUIElement")
54+
defer func() { logger.Tracef(ctx, "/handleClickUIElement") }()
55+
56+
textFilter := request.GetString("text", "")
57+
resourceIDFilter := request.GetString("resource_id", "")
58+
contentDescFilter := request.GetString("content_desc", "")
59+
60+
if textFilter == "" && resourceIDFilter == "" && contentDescFilter == "" {
61+
return mcp.NewToolResultError("at least one search filter is required (text, resource_id, or content_desc)"), nil
62+
}
63+
64+
xmlData, err := dumpUIXML()
65+
if err != nil {
66+
return mcp.NewToolResultError(fmt.Sprintf("dumping UI: %v", err)), nil
67+
}
68+
69+
elements := parseAndFilterUI(xmlData, textFilter, resourceIDFilter, contentDescFilter, "")
70+
71+
switch len(elements) {
72+
case 0:
73+
return mcp.NewToolResultError("no matching UI element found"), nil
74+
case 1:
75+
// Exactly one match — tap it.
76+
default:
77+
data, _ := json.Marshal(elements)
78+
return mcp.NewToolResultError(fmt.Sprintf(
79+
"found %d matching elements (use more specific criteria): %s",
80+
len(elements), string(data),
81+
)), nil
82+
}
83+
84+
elem := elements[0]
85+
cmd := fmt.Sprintf("input tap %d %d", elem.CenterX, elem.CenterY)
86+
if _, err := shellExec(cmd); err != nil {
87+
return mcp.NewToolResultError(fmt.Sprintf("input tap: %v", err)), nil
88+
}
89+
90+
result := ClickUIResult{
91+
MatchedText: elem.Text,
92+
MatchedResourceID: elem.ResourceID,
93+
MatchedContentDesc: elem.ContentDesc,
94+
TappedX: elem.CenterX,
95+
TappedY: elem.CenterY,
96+
}
97+
98+
data, err := json.Marshal(result)
99+
if err != nil {
100+
return nil, fmt.Errorf("marshaling click result: %w", err)
101+
}
102+
103+
return mcp.NewToolResultText(string(data)), nil
104+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
9+
"github.com/facebookincubator/go-belt/tool/logger"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
func registerDumpService(s *server.MCPServer) {
15+
tool := mcp.NewTool("dump_service",
16+
mcp.WithDescription(
17+
"Dump the state of an Android system service using 'dumpsys'. "+
18+
"Returns the full text dump. Common services: activity, window, "+
19+
"power, battery, wifi, connectivity, meminfo, cpuinfo, package, "+
20+
"notification, alarm, audio, display, input.",
21+
),
22+
mcp.WithString("service",
23+
mcp.Required(),
24+
mcp.Description("Service name (e.g. 'activity', 'battery', 'meminfo')"),
25+
),
26+
mcp.WithReadOnlyHintAnnotation(true),
27+
mcp.WithDestructiveHintAnnotation(false),
28+
mcp.WithIdempotentHintAnnotation(true),
29+
)
30+
31+
s.AddTool(tool, handleDumpService)
32+
}
33+
34+
func handleDumpService(
35+
ctx context.Context,
36+
request mcp.CallToolRequest,
37+
) (*mcp.CallToolResult, error) {
38+
logger.Tracef(ctx, "handleDumpService")
39+
defer func() { logger.Tracef(ctx, "/handleDumpService") }()
40+
41+
service, err := request.RequireString("service")
42+
if err != nil {
43+
return mcp.NewToolResultError(err.Error()), nil
44+
}
45+
46+
cmd := fmt.Sprintf("dumpsys %s", shellQuote(service))
47+
out, err := shellExec(cmd)
48+
if err != nil {
49+
return mcp.NewToolResultError(fmt.Sprintf("dumpsys: %v", err)), nil
50+
}
51+
52+
return mcp.NewToolResultText(out), nil
53+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//go:build linux
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"fmt"
8+
9+
"github.com/facebookincubator/go-belt/tool/logger"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
"github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
const uiDumpPath = "/data/local/tmp/ui.xml"
15+
16+
func registerDumpUIHierarchy(s *server.MCPServer) {
17+
tool := mcp.NewTool("dump_ui_hierarchy",
18+
mcp.WithDescription(
19+
"Dump the current UI hierarchy as XML using 'uiautomator dump'. "+
20+
"Returns the full XML tree of all visible UI elements with their "+
21+
"properties (text, resource-id, class, bounds, etc.). "+
22+
"This operation can take 2-3 seconds.",
23+
),
24+
mcp.WithReadOnlyHintAnnotation(true),
25+
mcp.WithDestructiveHintAnnotation(false),
26+
mcp.WithIdempotentHintAnnotation(true),
27+
)
28+
29+
s.AddTool(tool, handleDumpUIHierarchy)
30+
}
31+
32+
func handleDumpUIHierarchy(
33+
ctx context.Context,
34+
_ mcp.CallToolRequest,
35+
) (*mcp.CallToolResult, error) {
36+
logger.Tracef(ctx, "handleDumpUIHierarchy")
37+
defer func() { logger.Tracef(ctx, "/handleDumpUIHierarchy") }()
38+
39+
cmd := fmt.Sprintf("uiautomator dump %s && cat %s",
40+
shellQuote(uiDumpPath), shellQuote(uiDumpPath))
41+
42+
out, err := shellExec(cmd)
43+
if err != nil {
44+
return mcp.NewToolResultError(fmt.Sprintf("uiautomator dump: %v", err)), nil
45+
}
46+
47+
// uiautomator dump prints a status line before the XML in some versions.
48+
// The XML always starts with "<?xml" — strip any prefix.
49+
xml := extractXML(out)
50+
51+
return mcp.NewToolResultText(xml), nil
52+
}
53+
54+
// extractXML strips any non-XML prefix from uiautomator output.
55+
// The dump command sometimes prints "UI hierchary dumped to: /path"
56+
// before the XML when using 'dump && cat'.
57+
func extractXML(output string) string {
58+
for i := 0; i < len(output); i++ {
59+
if output[i] == '<' {
60+
return output[i:]
61+
}
62+
}
63+
return output
64+
}

0 commit comments

Comments
 (0)