android-agent-bridge 0.0.0-stage → 0.1.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/server/hub.mjs ADDED
@@ -0,0 +1,234 @@
1
+ // Hub: one port for everything.
2
+ // ws://<pc>:8350/phone the Android app dials in here (token checked in "hello")
3
+ // GET /pair pairing page with the QR code (this computer only)
4
+ // GET /status connected phones as JSON (this computer only)
5
+ // POST /rpc CLI commands from another process (this computer only, token header)
6
+ // GET /drop drag-and-drop page to send files to the phone (this computer only)
7
+ // /files/* file transfer both ways (see files.mjs)
8
+ import http from 'node:http';
9
+ import os from 'node:os';
10
+ import crypto from 'node:crypto';
11
+ import { WebSocketServer } from 'ws';
12
+ import QRCode from 'qrcode';
13
+ import { Files } from './files.mjs';
14
+ import { dropPage } from './droppage.mjs';
15
+
16
+ export function lanAddresses() {
17
+ const out = [];
18
+ for (const [name, list] of Object.entries(os.networkInterfaces())) {
19
+ if (/vethernet|virtualbox|vmware|docker|wsl|loopback/i.test(name)) continue;
20
+ for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export function pairLink(cfg, hosts = lanAddresses()) {
26
+ const q = new URLSearchParams({ h: hosts.join(','), p: String(cfg.port), t: cfg.token, n: os.hostname() });
27
+ return 'agentbridge://pair?' + q;
28
+ }
29
+
30
+ export const qrTerminal = (link) => QRCode.toString(link, { type: 'terminal', small: true });
31
+
32
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
33
+ const isLoopback = (req) => LOOPBACK.has(req.socket.remoteAddress);
34
+
35
+ function sameSecret(a, b) {
36
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
37
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
38
+ }
39
+
40
+ function readBody(req) {
41
+ return new Promise((resolve, reject) => {
42
+ let s = '';
43
+ req.on('data', (c) => { s += c; if (s.length > 1e6) req.destroy(); });
44
+ req.on('end', () => resolve(s));
45
+ req.on('error', reject);
46
+ });
47
+ }
48
+
49
+ export class Hub {
50
+ constructor({ cfg, log = () => {}, onRpc = null }) {
51
+ this.cfg = cfg;
52
+ this.port = cfg.port;
53
+ this.log = log;
54
+ this.onRpc = onRpc;
55
+ this.devices = new Map();
56
+ this.current = null;
57
+ this.pending = new Map();
58
+ this.nextId = 1;
59
+ this.nextDevice = 1;
60
+ this.waiters = [];
61
+ this.startError = null;
62
+ this.files = new Files({ log });
63
+ }
64
+
65
+ start() {
66
+ return new Promise((resolve) => {
67
+ this.server = http.createServer((req, res) => this.handleHttp(req, res).catch((err) => {
68
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: err.message }));
69
+ }));
70
+ this.wss = new WebSocketServer({ server: this.server, maxPayload: 64 * 1024 * 1024 });
71
+ this.wss.on('connection', (ws, req) => this.onConnection(ws, req));
72
+ this.wss.on('error', () => {}); // re-emitted server errors, handled below
73
+ this.server.on('error', (err) => {
74
+ this.startError = err.code === 'EADDRINUSE' ? `Port ${this.port} is already in use.` : err.message;
75
+ resolve(false);
76
+ });
77
+ this.server.listen(this.port, '0.0.0.0', () => {
78
+ this.log(`listening on port ${this.port} (Wi-Fi: ${lanAddresses().join(', ') || 'none'})`);
79
+ resolve(true);
80
+ });
81
+ });
82
+ }
83
+
84
+ async handleHttp(req, res) {
85
+ const url = new URL(req.url, 'http://localhost');
86
+ const json = (code, body) => res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(body));
87
+ // the phone uploads and downloads files from the Wi-Fi network too, so these check the token instead
88
+ if (url.pathname.startsWith('/files/')) {
89
+ return this.files.handle(req, res, url, { authed: sameSecret(req.headers['x-aab-token'] || '', this.cfg.token), local: isLoopback(req) });
90
+ }
91
+ if (!isLoopback(req)) return json(403, { ok: false, error: 'only reachable from this computer' });
92
+ if (req.method === 'GET' && url.pathname === '/drop') {
93
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(dropPage());
94
+ return;
95
+ }
96
+
97
+ if (req.method === 'GET' && url.pathname === '/status') return json(200, { ok: true, result: this.status() });
98
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/pair')) {
99
+ const link = pairLink(this.cfg);
100
+ const svg = await QRCode.toString(link, { type: 'svg', margin: 2, width: 320 });
101
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(pairPage(svg, link));
102
+ return;
103
+ }
104
+ if (req.method === 'POST' && url.pathname === '/rpc') {
105
+ if (!sameSecret(req.headers['x-aab-token'] || '', this.cfg.token)) return json(401, { ok: false, error: 'bad token' });
106
+ const { cmd, args, timeout } = JSON.parse((await readBody(req)) || '{}');
107
+ try {
108
+ return json(200, { ok: true, result: await this.onRpc(cmd, args || {}, timeout) });
109
+ } catch (err) {
110
+ return json(200, { ok: false, error: err.message });
111
+ }
112
+ }
113
+ json(404, { ok: false, error: 'not found' });
114
+ }
115
+
116
+ onConnection(ws, req) {
117
+ const device = { id: null, ws, info: null, address: req.socket.remoteAddress, connectedAt: Date.now() };
118
+ const helloTimer = setTimeout(() => ws.close(4001, 'no hello'), 5000);
119
+ ws.on('message', (data) => {
120
+ let msg;
121
+ try { msg = JSON.parse(String(data)); } catch { return; }
122
+ if (msg.type === 'hello') {
123
+ clearTimeout(helloTimer);
124
+ if (!sameSecret(msg.token || '', this.cfg.token)) {
125
+ ws.send(JSON.stringify({ type: 'rejected', error: 'Pairing code is out of date. Scan the QR code again.' }));
126
+ ws.close(4003, 'bad token');
127
+ this.log(`rejected a phone from ${device.address} (old pairing)`);
128
+ return;
129
+ }
130
+ device.id = 'p' + this.nextDevice++;
131
+ device.info = msg.info || {};
132
+ device.usb = LOOPBACK.has(device.address);
133
+ this.devices.set(device.id, device);
134
+ this.current = device;
135
+ ws.send(JSON.stringify({ type: 'welcome', name: os.hostname() }));
136
+ this.log(`phone connected: ${device.info.manufacturer} ${device.info.model}, Android ${device.info.release} (${device.usb ? 'USB' : device.address})`);
137
+ this.waiters.splice(0).forEach((w) => w(device));
138
+ return;
139
+ }
140
+ if (msg.type === 'info' && device.id) { device.info = msg.info || device.info; return; }
141
+ const p = msg.id && this.pending.get(msg.id);
142
+ if (!p) return;
143
+ this.pending.delete(msg.id);
144
+ clearTimeout(p.timer);
145
+ msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error));
146
+ });
147
+ ws.on('close', () => {
148
+ clearTimeout(helloTimer);
149
+ if (!device.id) return;
150
+ this.devices.delete(device.id);
151
+ if (this.current === device) this.current = [...this.devices.values()].pop() || null;
152
+ this.log(`phone disconnected: ${device.info?.model}`);
153
+ for (const [id, p] of this.pending) {
154
+ if (p.device !== device) continue;
155
+ this.pending.delete(id);
156
+ clearTimeout(p.timer);
157
+ p.reject(new Error('phone disconnected. It reconnects by itself within a few seconds; retry.'));
158
+ }
159
+ });
160
+ }
161
+
162
+ status() {
163
+ return {
164
+ port: this.port,
165
+ wifi: lanAddresses(),
166
+ phones: [...this.devices.values()].map((d) => ({ id: d.id, current: d === this.current, via: d.usb ? 'usb' : 'wifi', address: d.address, ...d.info })),
167
+ };
168
+ }
169
+
170
+ waitForDevice(ms) {
171
+ if (this.current) return Promise.resolve(this.current);
172
+ return new Promise((resolve) => {
173
+ const done = (d) => { clearTimeout(t); resolve(d); };
174
+ const t = setTimeout(() => { this.waiters = this.waiters.filter((w) => w !== done); resolve(null); }, ms);
175
+ this.waiters.push(done);
176
+ });
177
+ }
178
+
179
+ notConnectedHelp() {
180
+ if (this.startError) return this.startError;
181
+ return [
182
+ 'No phone connected.',
183
+ `• Wi-Fi: open http://localhost:${this.port}/pair on this computer and scan the QR code with the Agent Bridge app (same Wi-Fi network).`,
184
+ '• USB: plug the phone in with USB debugging on and run `npx android-agent-bridge usb`.',
185
+ `• Already paired? Open the Agent Bridge app and check that its accessibility service is on. Windows: allow Node.js through the firewall for port ${this.port}.`,
186
+ ].join('\n');
187
+ }
188
+
189
+ async call(cmd, args = {}, timeoutMs = 20000) {
190
+ const device = await this.waitForDevice(5000);
191
+ if (!device) throw new Error(this.notConnectedHelp());
192
+ const id = this.nextId++;
193
+ return new Promise((resolve, reject) => {
194
+ const timer = setTimeout(() => {
195
+ this.pending.delete(id);
196
+ reject(new Error(`phone did not answer "${cmd}" within ${timeoutMs}ms`));
197
+ }, timeoutMs);
198
+ this.pending.set(id, { resolve, reject, timer, device });
199
+ device.ws.send(JSON.stringify({ id, cmd, args }));
200
+ });
201
+ }
202
+
203
+ close() {
204
+ this.wss?.close();
205
+ this.server?.close();
206
+ }
207
+ }
208
+
209
+ function pairPage(svg, link) {
210
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
211
+ <title>Pair your phone</title>
212
+ <style>
213
+ :root{--bg:#f6f7f9;--fg:#15171a;--muted:#5d6470;--card:#fff;--ok:#127a3e}
214
+ @media (prefers-color-scheme:dark){:root{--bg:#121417;--fg:#eceef1;--muted:#9aa2ad;--card:#1c1f24;--ok:#4cc27e}}
215
+ body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif;display:grid;place-items:center;min-height:100vh;padding:16px;box-sizing:border-box}
216
+ main{background:var(--card);border-radius:16px;padding:28px;max-width:420px;text-align:center;box-shadow:0 2px 20px #0002}
217
+ h1{font-size:22px;margin:0 0 4px}p{color:var(--muted);margin:6px 0}
218
+ .qr{background:#fff;border-radius:12px;padding:8px;margin:18px auto;width:320px;max-width:100%}
219
+ .qr svg{width:100%;height:auto;display:block}
220
+ ol{text-align:left;color:var(--muted);padding-left:20px}
221
+ #st{font-weight:600;margin-top:12px}#st.ok{color:var(--ok)}
222
+ details{margin-top:12px;text-align:left;color:var(--muted);font-size:13px;word-break:break-all}
223
+ </style></head><body><main>
224
+ <h1>Pair your phone</h1><p>Give your AI agent access to your Android phone.</p>
225
+ <div class="qr">${svg}</div>
226
+ <ol><li>Open <b>Agent Bridge</b> on the phone.</li><li>Tap <b>Scan QR code</b> and point it here.</li><li>Phone and computer must be on the same Wi-Fi.</li></ol>
227
+ <div id="st">Waiting for the phone…</div>
228
+ <details><summary>Can't scan? Paste this link in the app</summary><code>${link}</code></details>
229
+ </main><script>
230
+ async function poll(){try{const r=await (await fetch('/status')).json();const p=r.result.phones;const el=document.getElementById('st');
231
+ if(p.length){el.textContent='Connected: '+p.map(x=>x.manufacturer+' '+x.model).join(', ')+'. You can close this page.';el.className='ok'}
232
+ else{el.textContent='Waiting for the phone…';el.className=''}}catch{}setTimeout(poll,1500)}poll();
233
+ </script></body></html>`;
234
+ }
package/server/mcp.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // MCP server: the phone as tools for AI agents. Actions return the updated
2
+ // element list (cheap text) so the agent only asks for a screenshot when it
3
+ // needs to see pixels.
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
7
+ import { createRequire } from 'node:module';
8
+ import { Bridge } from './bridge.mjs';
9
+
10
+ const { version: VERSION } = createRequire(import.meta.url)('../package.json');
11
+ const log = (...a) => process.stderr.write('[android-agent-bridge] ' + a.join(' ') + '\n');
12
+
13
+ const TARGET_HELP = 'what to act on: element number from the last screen ("12"), grid cell ("C7"), pixel coordinates ("540,1200") or visible text ("Sign in"; text missing from the element list is found by OCR)';
14
+ const after = {
15
+ after: z.boolean().optional().describe('return the updated element list afterwards (default true)'),
16
+ settle: z.number().int().optional().describe('max ms to wait for the screen to stop changing before reading it (default 2500)'),
17
+ };
18
+
19
+ const text = (s) => ({ content: [{ type: 'text', text: s }] });
20
+ const fail = (err) => ({ content: [{ type: 'text', text: 'Error: ' + (err?.message || String(err)) }], isError: true });
21
+ const wrap = (fn) => async (args) => { try { return await fn(args || {}); } catch (err) { return fail(err); } };
22
+
23
+ function actionResult(r, done) {
24
+ const parts = [done + (r.via === 'adb' ? ' (through adb)' : '')];
25
+ if (r.outline) parts.push(r.outline);
26
+ return text(parts.join('\n\n'));
27
+ }
28
+
29
+ export function registerTools(server, bridge) {
30
+ server.registerTool('phone_status', {
31
+ description: 'Is a phone connected? Shows model, Android version, how it is connected and what it can do. Call first; if nothing is connected it explains how to pair.',
32
+ inputSchema: {},
33
+ }, wrap(async () => {
34
+ const s = await bridge.run('status');
35
+ if (!s.phones.length) {
36
+ const p = await bridge.run('pair');
37
+ return text(`No phone connected.\nAsk the user to open ${p.page} on this computer and scan the QR code with the Agent Bridge app (same Wi-Fi), or to plug the phone in and run \`npx android-agent-bridge usb\`.\n\n${p.qr}`);
38
+ }
39
+ return text(s.phones.map((p) => `${p.current ? '* ' : ' '}${p.manufacturer} ${p.model} — Android ${p.release} (API ${p.sdk}), ${p.screenW}x${p.screenH}, via ${p.via}; gestures: ${p.gestures ? 'yes' : 'element taps only (USB adds coordinate taps)'}; screenshots: ${p.screenshot}`).join('\n'));
40
+ }));
41
+
42
+ server.registerTool('phone_screen', {
43
+ description: 'Screenshot of the phone with every tappable element boxed and numbered, plus the element list as text. mode "grid" overlays lettered cells (A1 top-left) for spots without an element (games, maps, images); "both" draws both. Use phone_elements instead when text is enough.',
44
+ inputSchema: {
45
+ mode: z.enum(['marks', 'grid', 'both', 'plain']).optional().describe('default "marks"'),
46
+ cols: z.number().int().min(4).max(30).optional().describe('grid columns (default 12; rows follow the screen shape)'),
47
+ maxSide: z.number().int().optional().describe('longest image side in px (default 1280)'),
48
+ },
49
+ }, wrap(async (a) => {
50
+ const r = await bridge.run('screen', a, 30000);
51
+ const content = [];
52
+ if (r.image) content.push({ type: 'image', data: r.image, mimeType: r.mime || 'image/jpeg' });
53
+ content.push({ type: 'text', text: [r.note || (r.captureError ? 'No screenshot: ' + r.captureError : ''), r.outline].filter(Boolean).join('\n\n') });
54
+ return { content };
55
+ }));
56
+
57
+ server.registerTool('phone_elements', {
58
+ description: 'Text list of what is on screen: numbered tappable elements, inputs, text and their positions. Cheaper than a screenshot. all=true also lists elements hidden under sheets or dialogs (for debugging).',
59
+ inputSchema: { all: z.boolean().optional().describe('include elements covered by something on top') },
60
+ }, wrap(async (a) => text((await bridge.run('elements', { all: !!a.all })).outline)));
61
+
62
+ server.registerTool('phone_ocr', {
63
+ description: 'Read the text drawn on the screen (on-device OCR) with positions. Use when something you can see in a screenshot is missing from phone_elements: many apps hide tab bars, rows or custom-drawn buttons from accessibility. phone_tap with that text also falls back to OCR automatically.',
64
+ inputSchema: {},
65
+ }, wrap(async () => text((await bridge.run('ocr', {}, 45000)).outline)));
66
+
67
+ server.registerTool('phone_tap', {
68
+ description: 'Tap (or long-press) something on the phone. ' + TARGET_HELP + '.',
69
+ inputSchema: { target: z.string().describe(TARGET_HELP), long: z.boolean().optional().describe('long press'), ...after },
70
+ }, wrap(async (a) => {
71
+ const r = await bridge.run('tap', a);
72
+ return actionResult(r, `${a.long ? 'Long-pressed' : 'Tapped'} ${a.target} at ${r.x},${r.y}${r.via === 'ocr' ? ` (found "${r.matched}" by reading the screen, it is not in the element list)` : ''}`);
73
+ }));
74
+
75
+ server.registerTool('phone_type', {
76
+ description: 'Type text into an input (no keyboard tapping needed). Without target it types into the focused input. submit presses Enter/Search/Send afterwards.',
77
+ inputSchema: {
78
+ text: z.string(),
79
+ target: z.string().optional().describe('input to type into: ' + TARGET_HELP),
80
+ append: z.boolean().optional().describe('keep existing text (default replaces it)'),
81
+ submit: z.boolean().optional().describe('press Enter afterwards'),
82
+ ...after,
83
+ },
84
+ }, wrap(async (a) => actionResult(await bridge.run('type', a), `Typed "${a.text}"${a.submit ? ' and submitted' : ''}`)));
85
+
86
+ server.registerTool('phone_scroll', {
87
+ description: 'Scroll the screen or one scrollable element. direction is where the content moves into view: "down" shows what is below.',
88
+ inputSchema: {
89
+ direction: z.enum(['up', 'down', 'left', 'right']),
90
+ target: z.string().optional().describe('scrollable element to scroll (default: the main scrollable area)'),
91
+ amount: z.number().min(0.1).max(1).optional().describe('fraction of the area to scroll (default 0.6)'),
92
+ ...after,
93
+ },
94
+ }, wrap(async (a) => actionResult(await bridge.run('scroll', a), `Scrolled ${a.direction}`)));
95
+
96
+ server.registerTool('phone_swipe', {
97
+ description: 'Drag from one point to another (sliders, swipe-to-dismiss, custom gestures). from/to take the same targets as phone_tap.',
98
+ inputSchema: { from: z.string(), to: z.string(), ms: z.number().int().optional().describe('duration, default 300'), ...after },
99
+ }, wrap(async (a) => actionResult(await bridge.run('swipe', a), `Swiped ${a.from} → ${a.to}`)));
100
+
101
+ server.registerTool('phone_key', {
102
+ description: 'System buttons and keys: back, home, recents, notifications, quick_settings, enter, lock, power_dialog.',
103
+ inputSchema: { key: z.enum(['back', 'home', 'recents', 'notifications', 'quick_settings', 'enter', 'lock', 'power_dialog']), ...after },
104
+ }, wrap(async (a) => actionResult(await bridge.run('key', a), `Pressed ${a.key}`)));
105
+
106
+ server.registerTool('phone_open_app', {
107
+ description: 'Open (launch) an installed app by its name ("WhatsApp") or package ("com.whatsapp"). With store=true, an app that is not installed is shown in the Play Store instead (nothing is installed automatically).',
108
+ inputSchema: {
109
+ name: z.string(),
110
+ store: z.boolean().optional().describe('if not installed, open its Play Store search'),
111
+ settle: z.number().int().optional().describe('max ms to wait for it to finish opening (default 5000)'),
112
+ after: z.boolean().optional(),
113
+ },
114
+ }, wrap(async (a) => {
115
+ const r = await bridge.run('open_app', { ...a, settle: a.settle ?? 5000 });
116
+ return actionResult(r, r.installed === false ? `${a.name} is not installed; opened the Play Store` : `Opened ${r.label} (${r.package})`);
117
+ }));
118
+
119
+ // ---- direct device commands: no navigating Settings by hand ----
120
+ const json = (r) => text(JSON.stringify(r, null, 1));
121
+
122
+ server.registerTool('phone_volume', {
123
+ description: 'Read or change volume. stream: media (default), ring, notification, alarm, call, system. action: get, up, down, mute, unmute, max, a level number, or a percentage like "40%". Returns every stream level and the ringer mode.',
124
+ inputSchema: {
125
+ stream: z.enum(['media', 'ring', 'notification', 'alarm', 'call', 'system']).optional(),
126
+ action: z.string().optional().describe('get | up | down | mute | unmute | max | <level> | <n>%'),
127
+ show: z.boolean().optional().describe('show the volume slider on screen'),
128
+ },
129
+ }, wrap(async (a) => json(await bridge.run('volume', { stream: a.stream || 'media', action: a.action || 'get', show: !!a.show }))));
130
+
131
+ server.registerTool('phone_brightness', {
132
+ description: 'Read or set screen brightness: "get", 0–100, "auto" or "manual". Needs "Modify system settings" once (phone_permissions grant=true over USB, or the phone opens that settings page).',
133
+ inputSchema: { value: z.string().optional() },
134
+ }, wrap(async (a) => json(await bridge.run('brightness', { value: a.value || 'get' }))));
135
+
136
+ server.registerTool('phone_toggle', {
137
+ description: 'Quick device switches. flashlight: on|off. rotation: get|auto|portrait|landscape (needs "Modify system settings" once). dnd (Do Not Disturb): get|on|alarms|total|off (needs DND access once). ringer: get|normal|vibrate|silent.',
138
+ inputSchema: { setting: z.enum(['flashlight', 'rotation', 'dnd', 'ringer']), value: z.string().optional() },
139
+ }, wrap(async (a) => {
140
+ const v = a.value || 'get';
141
+ if (a.setting === 'flashlight') return json(await bridge.run('torch', { state: v === 'get' ? 'on' : v }));
142
+ if (a.setting === 'rotation') return json(await bridge.run('rotation', { value: v }));
143
+ if (a.setting === 'dnd') return json(await bridge.run('dnd', { state: v }));
144
+ return json(await bridge.run('ringer', { mode: v === 'get' ? '' : v }));
145
+ }));
146
+
147
+ server.registerTool('phone_settings', {
148
+ description: 'Open a system settings page directly: settings, wifi, bluetooth, mobile-data, airplane, hotspot, nfc, location, display, sound, battery, storage, apps, notifications, security, privacy, accessibility, date, language, keyboard, developer, about, dnd, cast, vpn; Android 10+ panels: wifi-panel, internet-panel, volume-panel. Or app=<package> for an app\'s info page (permissions, storage, force stop).',
149
+ inputSchema: { page: z.string().optional(), app: z.string().optional() },
150
+ }, wrap(async (a) => actionResult(await bridge.run('settings_page', { page: a.page || (a.app ? '' : 'settings'), app: a.app || '' }), `Opened ${a.app ? 'app info for ' + a.app : a.page || 'settings'}`)));
151
+
152
+ server.registerTool('phone_open_link', {
153
+ description: 'Open a web page or a deep link on the phone (https://…, myapp://…, geo:lat,lng?q=…, mailto:…). package= forces a specific app (e.g. com.android.chrome).',
154
+ inputSchema: { url: z.string(), package: z.string().optional() },
155
+ }, wrap(async (a) => actionResult(await bridge.run('view_url', { url: a.url, package: a.package || '', settle: 4000 }), `Opened ${a.url}`)));
156
+
157
+ server.registerTool('phone_dial', {
158
+ description: 'Open the phone dialer with a number filled in. It never places the call; the user presses call. If several dialer apps are installed, Android shows "Complete action using": tap one.',
159
+ inputSchema: { number: z.string() },
160
+ }, wrap(async (a) => actionResult(await bridge.run('dial', { number: a.number, settle: 3000 }), `Dialer opened with ${a.number} (not calling)`)));
161
+
162
+ server.registerTool('phone_web_search', {
163
+ description: 'Run a web search on the phone (opens the default search app or browser).',
164
+ inputSchema: { query: z.string() },
165
+ }, wrap(async (a) => actionResult(await bridge.run('web_search', { query: a.query, settle: 4000 }), `Searched for ${a.query}`)));
166
+
167
+ server.registerTool('phone_device_info', {
168
+ description: 'Battery (level, charging, temperature, power saver, screen on), the app in front, and which permissions Agent Bridge has (system settings, Do Not Disturb, screen capture, gestures).',
169
+ inputSchema: {},
170
+ }, wrap(async () => json({ battery: await bridge.run('battery'), app: await bridge.run('current_app'), permissions: await bridge.run('permissions') })));
171
+
172
+ server.registerTool('phone_notifications', {
173
+ description: 'Notifications that arrived on the phone since the Agent Bridge service started (app, text, time), newest first.',
174
+ inputSchema: { limit: z.number().int().optional(), clear: z.boolean().optional() },
175
+ }, wrap(async (a) => json(await bridge.run('notifications', { limit: a.limit ?? 20, clear: !!a.clear }))));
176
+
177
+ server.registerTool('phone_clipboard', {
178
+ description: 'Read the phone clipboard, or set it (set="text"). Android 10+ may hide the clipboard from background apps, so reading can come back empty.',
179
+ inputSchema: { set: z.string().optional() },
180
+ }, wrap(async (a) => json(await bridge.run('clipboard', a.set != null ? { set: a.set } : {}))));
181
+
182
+ server.registerTool('phone_wake', {
183
+ description: 'Turn the screen on. A PIN, pattern or password lock stays locked: ask the user to unlock.',
184
+ inputSchema: {},
185
+ }, wrap(async () => json(await bridge.run('wake'))));
186
+
187
+ server.registerTool('phone_permissions', {
188
+ description: 'Check what Agent Bridge is allowed to do. grant=true switches on the one-time permissions (Modify system settings, Do Not Disturb access) over USB with adb.',
189
+ inputSchema: { grant: z.boolean().optional() },
190
+ }, wrap(async (a) => json(await bridge.run(a.grant ? 'grant_permissions' : 'permissions', {}, 30000))));
191
+
192
+ server.registerTool('phone_send_files', {
193
+ description: 'Send files from this computer to the phone (absolute paths). They are saved in Downloads/Agent Bridge on the phone. Works over Wi-Fi or USB.',
194
+ inputSchema: { paths: z.array(z.string()).min(1) },
195
+ }, wrap(async (a) => json(await bridge.run('send_files', { paths: a.paths }, 30 * 60 * 1000))));
196
+
197
+ server.registerTool('phone_received_files', {
198
+ description: 'Files the user sent from the phone to this computer (newest first) and the folder they are in, so you can open or read them.',
199
+ inputSchema: { limit: z.number().int().optional() },
200
+ }, wrap(async (a) => json(await bridge.run('received_files', { limit: a.limit ?? 20 }))));
201
+
202
+ server.registerTool('phone_stop_app', {
203
+ description: 'Force-stop an app by package (needs the phone on USB). Unsaved work in that app is lost.',
204
+ inputSchema: { package: z.string() },
205
+ }, wrap(async (a) => json(await bridge.run('force_stop', { package: a.package }))));
206
+
207
+ server.registerTool('phone_apps', {
208
+ description: 'List installed apps that can be opened (name and package).',
209
+ inputSchema: { filter: z.string().optional().describe('only names/packages containing this') },
210
+ }, wrap(async (a) => {
211
+ const r = await bridge.run('apps', a);
212
+ return text(r.apps.map((x) => `${x.label} — ${x.package}`).join('\n') || 'No apps match.');
213
+ }));
214
+
215
+ server.registerTool('phone_wait_for', {
216
+ description: 'Wait until some text appears on screen (page loads, messages arriving). Returns the element list.',
217
+ inputSchema: { text: z.string(), timeout: z.number().int().optional().describe('ms, default 10000') },
218
+ }, wrap(async (a) => {
219
+ const timeout = a.timeout ?? 10000;
220
+ const r = await bridge.run('wait_text', { text: a.text, timeout }, timeout + 5000);
221
+ return text(`${r.found ? 'Found' : 'Not found within ' + timeout + 'ms:'} "${a.text}"\n\n${r.outline}`);
222
+ }));
223
+ }
224
+
225
+ export async function startServer() {
226
+ const bridge = new Bridge({ log, host: true });
227
+ const server = new McpServer({ name: 'android-agent-bridge', version: VERSION });
228
+ registerTools(server, bridge);
229
+ await server.connect(new StdioServerTransport());
230
+ bridge.ensure().then(async () => {
231
+ if (bridge.hub) {
232
+ const p = await bridge.run('pair');
233
+ log(`pair a phone at ${p.page}`);
234
+ } else log('using the bridge already running on this computer');
235
+ }).catch((err) => log(err.message));
236
+ process.stdin.on('close', () => { bridge.close(); process.exit(0); });
237
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: phone-control
3
+ description: Use the user's Android phone through the android-agent-bridge MCP tools (phone_*) or the `phonectl` CLI — open apps, read the screen, tap, type, scroll, fill forms.
4
+ ---
5
+
6
+ # Using the user's Android phone
7
+
8
+ You control a real phone through the `phone_*` tools (or the `npx android-agent-bridge <command>` CLI with the same commands).
9
+
10
+ ## Loop
11
+
12
+ 1. `phone_status` first. If no phone is connected, give the user the pairing instructions it returns and stop.
13
+ 2. Look: `phone_elements` (cheap text) for most steps; `phone_screen` when you need pixels (images, icons without labels, games, verifying layout).
14
+ 3. Act with one tool call. Every action returns the updated element list, so you rarely need to look again separately.
15
+ 4. Repeat until done. Report what you did and what you saw.
16
+
17
+ ## Targets
18
+
19
+ `phone_tap`, `phone_type`, `phone_swipe`, `phone_scroll` take a target:
20
+
21
+ - `"12"`: element number from the latest `phone_elements` or `phone_screen` (most reliable). Numbers change when the screen changes, so use the newest list.
22
+ - `"Sign in"`: visible text or label on screen. The app's own controls win over the Android system bar, and text that the app hides from accessibility is found by reading the screen (OCR) automatically.
23
+ - `"C7"`: grid cell from `phone_screen` with `mode: "grid"`. Use this for spots with no element: maps, games, images, canvases.
24
+ - `"540,1200"`: screen pixels, as listed after `@` in the element list.
25
+
26
+ ## Tips
27
+
28
+ - Typing does not need the keyboard: `phone_type` with `target` puts the text straight into the field. Add `submit: true` to press Enter/Search.
29
+ - `phone_open_app` opens an app by name. Use `phone_key` `home`/`back` to navigate.
30
+ - `phone_scroll` `direction: "down"` brings what is below into view.
31
+ - `phone_wait_for` waits for text to appear after loading.
32
+ - Use the direct device tools instead of navigating Settings: `phone_volume`, `phone_brightness`, `phone_toggle` (flashlight, rotation, Do Not Disturb, ringer), `phone_settings` (jump to any settings page), `phone_open_link` (web pages and deep links), `phone_device_info` (battery, app in front, permissions), `phone_notifications`. `phone_dial` only fills in the number; it never calls.
33
+ - If you can see something in a screenshot that `phone_elements` doesn't list (custom tab bars, payment rows), call `phone_ocr`: it returns the text drawn on screen with positions. `phone_elements` with `all: true` also lists elements hidden under sheets.
34
+ - Banking, streaming and some other apps block screenshots; the element list usually still works.
35
+ - Ask the user before sending messages, posting, paying, deleting or anything else that can't be undone. Never type passwords or card numbers; ask the user to do that part on the phone.
36
+ - On Android 5–6 some actions need the phone plugged in over USB; the error message says so. Tell the user.