Skip to content

Commit 743a18f

Browse files
authored
Merge pull request #1560 from trheyi/main
feat(tools): add mobile commands and update documentation
2 parents 75b7a78 + 5e7d1fd commit 743a18f

16 files changed

Lines changed: 584 additions & 2 deletions

tools/mcps/mobile.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "yao-mobile",
3+
"transport": "process",
4+
"description": "Mobile device management tools for controlling Android devices connected via Tai Link",
5+
"tools": {
6+
"mobile_list": "tools.mobile_list",
7+
"mobile_exec": "tools.mobile_exec",
8+
"mobile_screenshot": "tools.mobile_screenshot",
9+
"mobile_info": "tools.mobile_info",
10+
"mobile_push": "tools.mobile_push",
11+
"mobile_pull": "tools.mobile_pull"
12+
}
13+
}

tools/mobile/exec.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package mobile
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"strings"
7+
8+
"github.com/yaoapp/gou/process"
9+
)
10+
11+
//go:embed exec_schema.json
12+
var ExecSchemaJSON []byte
13+
14+
// ExecHandler is the tools.mobile_exec process handler.
15+
// Executes a shell command on the target Android device.
16+
//
17+
// Args[0]: device_id (string)
18+
// Args[1]: command (string)
19+
func ExecHandler(proc *process.Process) interface{} {
20+
deviceID, err := extractDeviceID(proc, 0)
21+
if err != nil {
22+
return map[string]any{"error": err.Error()}
23+
}
24+
25+
command := proc.ArgsString(1)
26+
if command == "" {
27+
return map[string]any{"error": "command is required"}
28+
}
29+
30+
if _, err := getAndroidNode(deviceID); err != nil {
31+
return map[string]any{"error": err.Error()}
32+
}
33+
34+
ctx := context.Background()
35+
if proc.Context != nil {
36+
ctx = proc.Context
37+
}
38+
39+
stdout, stderr, exitCode, err := execOnDevice(ctx, deviceID, command)
40+
if err != nil {
41+
return map[string]any{
42+
"error": err.Error(),
43+
"stdout": strings.TrimSpace(stdout),
44+
"stderr": strings.TrimSpace(stderr),
45+
"exit_code": exitCode,
46+
}
47+
}
48+
49+
return map[string]any{
50+
"stdout": strings.TrimSpace(stdout),
51+
"stderr": strings.TrimSpace(stderr),
52+
"exit_code": exitCode,
53+
}
54+
}

tools/mobile/exec_schema.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "mobile_exec",
3+
"description": "Execute a shell command on a connected Android device via Tai Link. Returns stdout, stderr, and exit code.",
4+
"process": "tools.mobile_exec",
5+
"inputSchema": {
6+
"type": "object",
7+
"properties": {
8+
"device_id": {
9+
"type": "string",
10+
"description": "The Tai ID of the target Android device (from mobile_list)"
11+
},
12+
"command": {
13+
"type": "string",
14+
"description": "Shell command to execute on the device"
15+
}
16+
},
17+
"required": ["device_id", "command"]
18+
},
19+
"x-process-args": ["$args.device_id", "$args.command"]
20+
}

tools/mobile/info.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package mobile
2+
3+
import (
4+
_ "embed"
5+
6+
"github.com/yaoapp/gou/process"
7+
)
8+
9+
//go:embed info_schema.json
10+
var InfoSchemaJSON []byte
11+
12+
// InfoHandler is the tools.mobile_info process handler.
13+
// Returns detailed information about a connected Android device.
14+
//
15+
// Args[0]: device_id (string)
16+
func InfoHandler(proc *process.Process) interface{} {
17+
deviceID, err := extractDeviceID(proc, 0)
18+
if err != nil {
19+
return map[string]any{"error": err.Error()}
20+
}
21+
22+
meta, err := getAndroidNode(deviceID)
23+
if err != nil {
24+
return map[string]any{"error": err.Error()}
25+
}
26+
27+
caps := map[string]bool{}
28+
if meta.Capabilities.HostExec {
29+
caps["host_exec"] = true
30+
}
31+
if meta.Capabilities.Docker {
32+
caps["docker"] = true
33+
}
34+
if meta.Capabilities.K8s {
35+
caps["k8s"] = true
36+
}
37+
if meta.Capabilities.VNC {
38+
caps["vnc"] = true
39+
}
40+
41+
return map[string]any{
42+
"device_id": meta.TaiID,
43+
"machine_id": meta.MachineID,
44+
"display_name": meta.DisplayName,
45+
"hostname": meta.System.Hostname,
46+
"os": meta.System.OS,
47+
"arch": meta.System.Arch,
48+
"status": meta.Status,
49+
"capabilities": caps,
50+
}
51+
}

tools/mobile/info_schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "mobile_info",
3+
"description": "Get detailed information about a connected Android device, including OS, architecture, hostname, capabilities, and status.",
4+
"process": "tools.mobile_info",
5+
"inputSchema": {
6+
"type": "object",
7+
"properties": {
8+
"device_id": {
9+
"type": "string",
10+
"description": "The Tai ID of the target Android device (from mobile_list)"
11+
}
12+
},
13+
"required": ["device_id"]
14+
},
15+
"x-process-args": ["$args.device_id"]
16+
}

tools/mobile/list.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package mobile
2+
3+
import (
4+
_ "embed"
5+
6+
"github.com/yaoapp/gou/process"
7+
)
8+
9+
//go:embed list_schema.json
10+
var ListSchemaJSON []byte
11+
12+
// ListHandler is the tools.mobile_list process handler.
13+
// Returns all online Android devices.
14+
func ListHandler(proc *process.Process) interface{} {
15+
nodes := listAndroidNodes()
16+
devices := make([]map[string]any, 0, len(nodes))
17+
for _, n := range nodes {
18+
devices = append(devices, map[string]any{
19+
"device_id": n.TaiID,
20+
"display_name": n.DisplayName,
21+
"model": n.System.Hostname,
22+
"os": n.System.OS,
23+
"arch": n.System.Arch,
24+
"status": n.Status,
25+
})
26+
}
27+
return map[string]any{"devices": devices}
28+
}

tools/mobile/list_schema.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "mobile_list",
3+
"description": "List all online Android mobile devices connected via Tai Link.",
4+
"process": "tools.mobile_list",
5+
"inputSchema": {
6+
"type": "object",
7+
"properties": {}
8+
},
9+
"x-process-args": []
10+
}

tools/mobile/mobile.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package mobile
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/yaoapp/gou/process"
8+
tai "github.com/yaoapp/yao/tai"
9+
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
10+
"github.com/yaoapp/yao/tai/registry"
11+
"github.com/yaoapp/yao/tai/types"
12+
)
13+
14+
func listAndroidNodes() []types.NodeMeta {
15+
reg := registry.Global()
16+
if reg == nil {
17+
return nil
18+
}
19+
all := reg.List()
20+
var result []types.NodeMeta
21+
for _, n := range all {
22+
if n.System.OS == "android" && n.Status == "online" {
23+
result = append(result, n)
24+
}
25+
}
26+
return result
27+
}
28+
29+
func getAndroidNode(deviceID string) (*types.NodeMeta, error) {
30+
meta, ok := tai.GetNodeMeta(deviceID)
31+
if !ok {
32+
return nil, fmt.Errorf("device %q not found", deviceID)
33+
}
34+
if meta.Status != "online" {
35+
return nil, fmt.Errorf("device %q is offline", deviceID)
36+
}
37+
if meta.System.OS != "android" {
38+
return nil, fmt.Errorf("device %q is not an Android device (os=%s)", deviceID, meta.System.OS)
39+
}
40+
return meta, nil
41+
}
42+
43+
func getHostExec(deviceID string) (hepb.HostExecClient, func(), error) {
44+
res, ok := tai.GetResources(deviceID)
45+
if !ok {
46+
return nil, nil, fmt.Errorf("device %q: no connection resources", deviceID)
47+
}
48+
if res.HostExec == nil {
49+
return nil, nil, fmt.Errorf("device %q: host_exec not enabled", deviceID)
50+
}
51+
return res.HostExec, func() {}, nil
52+
}
53+
54+
func execOnDevice(ctx context.Context, deviceID, command string) (string, string, int32, error) {
55+
he, cleanup, err := getHostExec(deviceID)
56+
if err != nil {
57+
return "", "", -1, err
58+
}
59+
defer cleanup()
60+
61+
resp, err := he.Exec(ctx, &hepb.ExecRequest{
62+
Command: "sh",
63+
Args: []string{"-c", command},
64+
TimeoutMs: 60000,
65+
})
66+
if err != nil {
67+
return "", "", -1, fmt.Errorf("exec rpc: %w", err)
68+
}
69+
70+
stdout := string(resp.Stdout)
71+
stderr := string(resp.Stderr)
72+
if resp.Error != "" {
73+
return stdout, stderr, resp.ExitCode, fmt.Errorf("%s", resp.Error)
74+
}
75+
return stdout, stderr, resp.ExitCode, nil
76+
}
77+
78+
func extractDeviceID(proc *process.Process, idx int) (string, error) {
79+
id := proc.ArgsString(idx)
80+
if id == "" {
81+
return "", fmt.Errorf("device_id is required")
82+
}
83+
return id, nil
84+
}

tools/mobile/pull.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package mobile
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"encoding/base64"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/yaoapp/gou/process"
12+
tai "github.com/yaoapp/yao/tai"
13+
)
14+
15+
//go:embed pull_schema.json
16+
var PullSchemaJSON []byte
17+
18+
// PullHandler is the tools.mobile_pull process handler.
19+
// Pulls a file from the Android device to the Yao server (or returns base64).
20+
//
21+
// Args[0]: device_id (string)
22+
// Args[1]: remote_path (string) - source path on the device
23+
// Args[2]: local_path (string, optional) - destination path on the Yao server; if empty, returns content as base64
24+
func PullHandler(proc *process.Process) interface{} {
25+
deviceID, err := extractDeviceID(proc, 0)
26+
if err != nil {
27+
return map[string]any{"error": err.Error()}
28+
}
29+
30+
remotePath := proc.ArgsString(1)
31+
localPath := proc.ArgsString(2)
32+
33+
if remotePath == "" {
34+
return map[string]any{"error": "remote_path is required"}
35+
}
36+
37+
if _, err := getAndroidNode(deviceID); err != nil {
38+
return map[string]any{"error": err.Error()}
39+
}
40+
41+
res, ok := tai.GetResources(deviceID)
42+
if !ok {
43+
return map[string]any{"error": fmt.Sprintf("device %q: no connection resources", deviceID)}
44+
}
45+
if res.Volume == nil {
46+
return map[string]any{"error": fmt.Sprintf("device %q: volume not enabled", deviceID)}
47+
}
48+
49+
ctx := context.Background()
50+
if proc.Context != nil {
51+
ctx = proc.Context
52+
}
53+
54+
data, _, err := res.Volume.ReadFile(ctx, deviceID, remotePath)
55+
if err != nil {
56+
return map[string]any{"error": fmt.Sprintf("read from device: %v", err)}
57+
}
58+
59+
if localPath != "" {
60+
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
61+
return map[string]any{"error": fmt.Sprintf("create local dir: %v", err)}
62+
}
63+
if err := os.WriteFile(localPath, data, 0644); err != nil {
64+
return map[string]any{"error": fmt.Sprintf("write local file: %v", err)}
65+
}
66+
return map[string]any{
67+
"success": true,
68+
"local_path": localPath,
69+
"bytes": len(data),
70+
}
71+
}
72+
73+
return map[string]any{
74+
"content_base64": base64.StdEncoding.EncodeToString(data),
75+
"remote_path": remotePath,
76+
"bytes": len(data),
77+
}
78+
}

tools/mobile/pull_schema.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "mobile_pull",
3+
"description": "Pull a file from a connected Android device. If local_path is provided, saves to the Yao server; otherwise returns the file content as base64.",
4+
"process": "tools.mobile_pull",
5+
"inputSchema": {
6+
"type": "object",
7+
"properties": {
8+
"device_id": {
9+
"type": "string",
10+
"description": "The Tai ID of the target Android device (from mobile_list)"
11+
},
12+
"remote_path": {
13+
"type": "string",
14+
"description": "Source file path on the Android device"
15+
},
16+
"local_path": {
17+
"type": "string",
18+
"description": "Destination path on the Yao server (optional; if omitted, content is returned as base64)"
19+
}
20+
},
21+
"required": ["device_id", "remote_path"]
22+
},
23+
"x-process-args": ["$args.device_id", "$args.remote_path", "$args.local_path"]
24+
}

0 commit comments

Comments
 (0)