@kernel.chat/kbot 3.71.0 → 3.72.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/cli.js +7 -1
- package/dist/integrations/ableton-bridge.d.ts +3 -3
- package/dist/integrations/ableton-bridge.js +6 -6
- package/dist/integrations/ableton-m4l.d.ts +4 -4
- package/dist/integrations/ableton-m4l.js +7 -7
- package/dist/integrations/install-remote-script.d.ts +1 -1
- package/dist/integrations/install-remote-script.js +4 -4
- package/dist/integrations/mobile-mcp-client.d.ts +111 -0
- package/dist/integrations/mobile-mcp-client.js +343 -0
- package/dist/serve.d.ts +3 -0
- package/dist/serve.js +51 -7
- package/dist/tools/index.js +2 -0
- package/dist/tools/iphone.d.ts +2 -0
- package/dist/tools/iphone.js +800 -0
- package/dist/tools/mobile-automation.d.ts +2 -0
- package/dist/tools/mobile-automation.js +612 -0
- package/dist/tools/serum2-preset.js +27 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1962,16 +1962,22 @@ async function main() {
|
|
|
1962
1962
|
});
|
|
1963
1963
|
program
|
|
1964
1964
|
.command('serve')
|
|
1965
|
-
.description('Start HTTP server — expose all
|
|
1965
|
+
.description('Start HTTP/HTTPS server — expose all tools for kernel.chat, Claude Cowork, or any client')
|
|
1966
1966
|
.option('-p, --port <port>', 'Port to listen on', '7437')
|
|
1967
1967
|
.option('--token <token>', 'Require auth token for all requests')
|
|
1968
1968
|
.option('--computer-use', 'Enable computer use tools')
|
|
1969
|
+
.option('--https', 'Enable HTTPS with auto-generated self-signed cert (~/.kbot/certs/)')
|
|
1970
|
+
.option('--cert <path>', 'Path to TLS certificate file (implies HTTPS)')
|
|
1971
|
+
.option('--key <path>', 'Path to TLS private key file (implies HTTPS)')
|
|
1969
1972
|
.action(async (opts) => {
|
|
1970
1973
|
const { startServe } = await import('./serve.js');
|
|
1971
1974
|
await startServe({
|
|
1972
1975
|
port: parseInt(opts.port, 10),
|
|
1973
1976
|
token: opts.token,
|
|
1974
1977
|
computerUse: opts.computerUse,
|
|
1978
|
+
https: opts.https,
|
|
1979
|
+
cert: opts.cert,
|
|
1980
|
+
key: opts.key,
|
|
1975
1981
|
});
|
|
1976
1982
|
});
|
|
1977
1983
|
program
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Fallback chain (used by tools):
|
|
13
13
|
* 1. AbletonBridge (port 9001) — full browser API
|
|
14
|
-
* 2. KBotBridge (port
|
|
14
|
+
* 2. KBotBridge (port 9997) — kbot's own Remote Script
|
|
15
15
|
* 3. Error with install instructions
|
|
16
16
|
*
|
|
17
17
|
* Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
|
|
@@ -106,7 +106,7 @@ export declare class AbletonBridgeClient {
|
|
|
106
106
|
getEffectChain(trackIndex: number): Promise<Device[]>;
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
|
-
* Lightweight TCP probe for the kbot Remote Script on port
|
|
109
|
+
* Lightweight TCP probe for the kbot Remote Script on port 9997.
|
|
110
110
|
* Uses the same newline-delimited JSON protocol as AbletonM4L.
|
|
111
111
|
*/
|
|
112
112
|
export declare class KBotRemoteClient {
|
|
@@ -139,7 +139,7 @@ export declare class KBotRemoteClient {
|
|
|
139
139
|
*/
|
|
140
140
|
export declare function tryAbletonBridge(): Promise<AbletonBridgeClient | null>;
|
|
141
141
|
/**
|
|
142
|
-
* Try to connect to KBotBridge Remote Script (port
|
|
142
|
+
* Try to connect to KBotBridge Remote Script (port 9997).
|
|
143
143
|
* Returns the connected client or null if unavailable.
|
|
144
144
|
*/
|
|
145
145
|
export declare function tryKBotRemote(): Promise<KBotRemoteClient | null>;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Fallback chain (used by tools):
|
|
13
13
|
* 1. AbletonBridge (port 9001) — full browser API
|
|
14
|
-
* 2. KBotBridge (port
|
|
14
|
+
* 2. KBotBridge (port 9997) — kbot's own Remote Script
|
|
15
15
|
* 3. Error with install instructions
|
|
16
16
|
*
|
|
17
17
|
* Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
|
|
@@ -268,9 +268,9 @@ export class AbletonBridgeClient {
|
|
|
268
268
|
}));
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
|
-
// ── KBotBridge fallback (port
|
|
271
|
+
// ── KBotBridge fallback (port 9997) ────────────────────────────────────
|
|
272
272
|
/**
|
|
273
|
-
* Lightweight TCP probe for the kbot Remote Script on port
|
|
273
|
+
* Lightweight TCP probe for the kbot Remote Script on port 9997.
|
|
274
274
|
* Uses the same newline-delimited JSON protocol as AbletonM4L.
|
|
275
275
|
*/
|
|
276
276
|
export class KBotRemoteClient {
|
|
@@ -280,7 +280,7 @@ export class KBotRemoteClient {
|
|
|
280
280
|
pending = new Map();
|
|
281
281
|
nextId = 1;
|
|
282
282
|
buffer = '';
|
|
283
|
-
static PORT =
|
|
283
|
+
static PORT = 9997;
|
|
284
284
|
static HOST = '127.0.0.1';
|
|
285
285
|
static TIMEOUT = 10_000;
|
|
286
286
|
static CONNECT_TIMEOUT = 3_000;
|
|
@@ -431,7 +431,7 @@ export async function tryAbletonBridge() {
|
|
|
431
431
|
return ok ? client : null;
|
|
432
432
|
}
|
|
433
433
|
/**
|
|
434
|
-
* Try to connect to KBotBridge Remote Script (port
|
|
434
|
+
* Try to connect to KBotBridge Remote Script (port 9997).
|
|
435
435
|
* Returns the connected client or null if unavailable.
|
|
436
436
|
*/
|
|
437
437
|
export async function tryKBotRemote() {
|
|
@@ -478,7 +478,7 @@ export function formatBridgeError() {
|
|
|
478
478
|
' kbot\'s own Remote Script. Install:',
|
|
479
479
|
' 1. Run `kbot ableton install` or copy KBotBridge to Remote Scripts',
|
|
480
480
|
' 2. Enable in Ableton: Preferences → Link/Tempo/MIDI → Control Surface → KBotBridge',
|
|
481
|
-
' 3. Verify: TCP server starts on localhost:
|
|
481
|
+
' 3. Verify: TCP server starts on localhost:9997',
|
|
482
482
|
'',
|
|
483
483
|
'Both require Ableton Live to be running.',
|
|
484
484
|
].join('\n');
|
|
@@ -124,7 +124,7 @@ export interface BrowserCategory {
|
|
|
124
124
|
child_count: number;
|
|
125
125
|
}
|
|
126
126
|
/**
|
|
127
|
-
* Client for the KBotBridge Remote Script (TCP
|
|
127
|
+
* Client for the KBotBridge Remote Script (TCP 9997).
|
|
128
128
|
*
|
|
129
129
|
* This is separate from the M4L bridge (9999) because the Browser API
|
|
130
130
|
* (browser.load_item) is ONLY available from Python Remote Scripts,
|
|
@@ -146,7 +146,7 @@ export declare class AbletonBrowserBridge {
|
|
|
146
146
|
private constructor();
|
|
147
147
|
static getInstance(): AbletonBrowserBridge;
|
|
148
148
|
/**
|
|
149
|
-
* Connect to the KBotBridge Remote Script on port
|
|
149
|
+
* Connect to the KBotBridge Remote Script on port 9997.
|
|
150
150
|
* Returns true if connected and the bridge responds to ping.
|
|
151
151
|
*/
|
|
152
152
|
connect(): Promise<boolean>;
|
|
@@ -195,12 +195,12 @@ export declare class AbletonBrowserBridge {
|
|
|
195
195
|
*/
|
|
196
196
|
export declare function ensureM4L(): Promise<AbletonM4L>;
|
|
197
197
|
/**
|
|
198
|
-
* Get a connected Browser bridge instance (KBotBridge Remote Script on port
|
|
198
|
+
* Get a connected Browser bridge instance (KBotBridge Remote Script on port 9997).
|
|
199
199
|
* Throws if not available.
|
|
200
200
|
*/
|
|
201
201
|
export declare function ensureBrowserBridge(): Promise<AbletonBrowserBridge>;
|
|
202
202
|
/**
|
|
203
|
-
* Connect to both M4L bridge (9999) and Browser bridge (
|
|
203
|
+
* Connect to both M4L bridge (9999) and Browser bridge (9997).
|
|
204
204
|
* Returns whichever connections succeed. At least one must connect.
|
|
205
205
|
*/
|
|
206
206
|
export declare function connectBrowser(): Promise<{
|
|
@@ -302,7 +302,7 @@ export class AbletonM4L {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
/**
|
|
305
|
-
* Client for the KBotBridge Remote Script (TCP
|
|
305
|
+
* Client for the KBotBridge Remote Script (TCP 9997).
|
|
306
306
|
*
|
|
307
307
|
* This is separate from the M4L bridge (9999) because the Browser API
|
|
308
308
|
* (browser.load_item) is ONLY available from Python Remote Scripts,
|
|
@@ -318,7 +318,7 @@ export class AbletonBrowserBridge {
|
|
|
318
318
|
pending = new Map();
|
|
319
319
|
nextId = 1;
|
|
320
320
|
buffer = '';
|
|
321
|
-
static PORT =
|
|
321
|
+
static PORT = 9997;
|
|
322
322
|
static HOST = '127.0.0.1';
|
|
323
323
|
static TIMEOUT = 15_000; // Browser operations can be slow
|
|
324
324
|
constructor() { }
|
|
@@ -329,7 +329,7 @@ export class AbletonBrowserBridge {
|
|
|
329
329
|
return AbletonBrowserBridge.instance;
|
|
330
330
|
}
|
|
331
331
|
/**
|
|
332
|
-
* Connect to the KBotBridge Remote Script on port
|
|
332
|
+
* Connect to the KBotBridge Remote Script on port 9997.
|
|
333
333
|
* Returns true if connected and the bridge responds to ping.
|
|
334
334
|
*/
|
|
335
335
|
async connect() {
|
|
@@ -518,7 +518,7 @@ export async function ensureM4L() {
|
|
|
518
518
|
return m4l;
|
|
519
519
|
}
|
|
520
520
|
/**
|
|
521
|
-
* Get a connected Browser bridge instance (KBotBridge Remote Script on port
|
|
521
|
+
* Get a connected Browser bridge instance (KBotBridge Remote Script on port 9997).
|
|
522
522
|
* Throws if not available.
|
|
523
523
|
*/
|
|
524
524
|
export async function ensureBrowserBridge() {
|
|
@@ -531,13 +531,13 @@ export async function ensureBrowserBridge() {
|
|
|
531
531
|
'Make sure:\n' +
|
|
532
532
|
'1. Ableton Live is running\n' +
|
|
533
533
|
'2. KBotBridge is selected as a Control Surface in Preferences > Link, Tempo & MIDI\n' +
|
|
534
|
-
'3. Ableton status bar shows "KBotBridge: Listening on port
|
|
534
|
+
'3. Ableton status bar shows "KBotBridge: Listening on port 9997"\n\n' +
|
|
535
535
|
'To install: kbot ableton install-bridge\n');
|
|
536
536
|
}
|
|
537
537
|
return bridge;
|
|
538
538
|
}
|
|
539
539
|
/**
|
|
540
|
-
* Connect to both M4L bridge (9999) and Browser bridge (
|
|
540
|
+
* Connect to both M4L bridge (9999) and Browser bridge (9997).
|
|
541
541
|
* Returns whichever connections succeed. At least one must connect.
|
|
542
542
|
*/
|
|
543
543
|
export async function connectBrowser() {
|
|
@@ -582,7 +582,7 @@ export function formatBrowserBridgeError() {
|
|
|
582
582
|
'5. Close Preferences',
|
|
583
583
|
'',
|
|
584
584
|
'This runs alongside the M4L bridge — they use different ports:',
|
|
585
|
-
'- KBotBridge: TCP
|
|
585
|
+
'- KBotBridge: TCP 9997 (Browser API, device loading)',
|
|
586
586
|
'- M4L Bridge: TCP 9999 (LOM access, clips, mixing)',
|
|
587
587
|
].join('\n');
|
|
588
588
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* install-remote-script.ts — Install KBotBridge Remote Script into Ableton Live
|
|
3
3
|
*
|
|
4
4
|
* Copies the KBotBridge Python Remote Script to Ableton's User Library,
|
|
5
|
-
* enabling the Browser API bridge on TCP port
|
|
5
|
+
* enabling the Browser API bridge on TCP port 9997.
|
|
6
6
|
*
|
|
7
7
|
* The Remote Script exposes Ableton's browser.load_item() API, which is
|
|
8
8
|
* ONLY available from Python Remote Scripts (not from Max for Live).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* install-remote-script.ts — Install KBotBridge Remote Script into Ableton Live
|
|
3
3
|
*
|
|
4
4
|
* Copies the KBotBridge Python Remote Script to Ableton's User Library,
|
|
5
|
-
* enabling the Browser API bridge on TCP port
|
|
5
|
+
* enabling the Browser API bridge on TCP port 9997.
|
|
6
6
|
*
|
|
7
7
|
* The Remote Script exposes Ableton's browser.load_item() API, which is
|
|
8
8
|
* ONLY available from Python Remote Scripts (not from Max for Live).
|
|
@@ -84,11 +84,11 @@ export async function installKBotBridge() {
|
|
|
84
84
|
log(' 5. Close Preferences');
|
|
85
85
|
log('');
|
|
86
86
|
log('Verify:');
|
|
87
|
-
log(' - Ableton status bar shows "KBotBridge: Listening on port
|
|
88
|
-
log(' - Run: echo \'{"id":1,"action":"ping"}\\n\' | nc localhost
|
|
87
|
+
log(' - Ableton status bar shows "KBotBridge: Listening on port 9997"');
|
|
88
|
+
log(' - Run: echo \'{"id":1,"action":"ping"}\\n\' | nc localhost 9997');
|
|
89
89
|
log('');
|
|
90
90
|
log('KBotBridge runs alongside AbletonOSC — they use different ports:');
|
|
91
|
-
log(' - KBotBridge: TCP
|
|
91
|
+
log(' - KBotBridge: TCP 9997 (Browser API, device loading)');
|
|
92
92
|
log(' - M4L Bridge: TCP 9999 (LOM access, clips, mixing)');
|
|
93
93
|
log(' - AbletonOSC: UDP 11000/11001 (OSC, legacy)');
|
|
94
94
|
return lines.join('\n');
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-mcp-client.ts — kbot <-> mobile-mcp integration
|
|
3
|
+
*
|
|
4
|
+
* Singleton client that manages the mobile-mcp server process lifecycle.
|
|
5
|
+
* Communicates via MCP protocol over stdio transport.
|
|
6
|
+
* Auto-installs @mobilenext/mobile-mcp via npm if not present.
|
|
7
|
+
*
|
|
8
|
+
* mobile-mcp provides native accessibility-tree-based automation for
|
|
9
|
+
* iOS and Android devices connected via USB or WiFi.
|
|
10
|
+
*
|
|
11
|
+
* @see https://github.com/mobile-next/mobile-mcp
|
|
12
|
+
*/
|
|
13
|
+
export interface MobileDevice {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
platform: 'ios' | 'android';
|
|
17
|
+
type: 'real' | 'simulator' | 'emulator';
|
|
18
|
+
version: string;
|
|
19
|
+
state: 'online' | 'offline';
|
|
20
|
+
}
|
|
21
|
+
export interface MobileElement {
|
|
22
|
+
type: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
value?: string;
|
|
27
|
+
identifier?: string;
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
export interface MobileScreenSize {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}
|
|
37
|
+
export declare class MobileMCPClient {
|
|
38
|
+
private static instance;
|
|
39
|
+
private process;
|
|
40
|
+
private messageId;
|
|
41
|
+
private pending;
|
|
42
|
+
private buffer;
|
|
43
|
+
private initialized;
|
|
44
|
+
private activeDeviceId;
|
|
45
|
+
static getInstance(): MobileMCPClient;
|
|
46
|
+
/** Whether the MCP server process is running and initialized */
|
|
47
|
+
get isConnected(): boolean;
|
|
48
|
+
/** The device ID currently being controlled */
|
|
49
|
+
get currentDeviceId(): string | null;
|
|
50
|
+
/** Start the mobile-mcp server process and perform MCP handshake */
|
|
51
|
+
start(): Promise<void>;
|
|
52
|
+
/** Stop the mobile-mcp server process */
|
|
53
|
+
stop(): void;
|
|
54
|
+
private parseMessages;
|
|
55
|
+
private sendRequest;
|
|
56
|
+
private sendNotification;
|
|
57
|
+
/** Call a tool on the mobile-mcp server */
|
|
58
|
+
callTool(toolName: string, args: Record<string, unknown>): Promise<unknown>;
|
|
59
|
+
/** Extract text content from an MCP tool result */
|
|
60
|
+
extractText(result: unknown): string;
|
|
61
|
+
/** Extract image content (base64) from an MCP tool result */
|
|
62
|
+
extractImage(result: unknown): {
|
|
63
|
+
data: string;
|
|
64
|
+
mimeType: string;
|
|
65
|
+
} | null;
|
|
66
|
+
/** List all available devices */
|
|
67
|
+
listDevices(): Promise<MobileDevice[]>;
|
|
68
|
+
/** Set the active device for subsequent operations */
|
|
69
|
+
setActiveDevice(deviceId: string): void;
|
|
70
|
+
/** Get the active device ID, throwing if none set */
|
|
71
|
+
private requireDevice;
|
|
72
|
+
/** List apps on the active device */
|
|
73
|
+
listApps(deviceId?: string): Promise<string>;
|
|
74
|
+
/** Launch an app by bundle ID */
|
|
75
|
+
launchApp(packageName: string, deviceId?: string): Promise<string>;
|
|
76
|
+
/** Take a screenshot, returns base64 image data */
|
|
77
|
+
takeScreenshot(deviceId?: string): Promise<{
|
|
78
|
+
data: string;
|
|
79
|
+
mimeType: string;
|
|
80
|
+
} | string>;
|
|
81
|
+
/** Save screenshot to a file */
|
|
82
|
+
saveScreenshot(saveTo: string, deviceId?: string): Promise<string>;
|
|
83
|
+
/** List UI elements on screen via accessibility tree */
|
|
84
|
+
listElements(deviceId?: string): Promise<string>;
|
|
85
|
+
/** Tap at coordinates */
|
|
86
|
+
tap(x: number, y: number, deviceId?: string): Promise<string>;
|
|
87
|
+
/** Swipe on screen */
|
|
88
|
+
swipe(direction: 'up' | 'down' | 'left' | 'right', opts?: {
|
|
89
|
+
x?: number;
|
|
90
|
+
y?: number;
|
|
91
|
+
distance?: number;
|
|
92
|
+
deviceId?: string;
|
|
93
|
+
}): Promise<string>;
|
|
94
|
+
/** Type text */
|
|
95
|
+
typeText(text: string, submit?: boolean, deviceId?: string): Promise<string>;
|
|
96
|
+
/** Press a device button */
|
|
97
|
+
pressButton(button: 'HOME' | 'BACK' | 'VOLUME_UP' | 'VOLUME_DOWN' | 'ENTER', deviceId?: string): Promise<string>;
|
|
98
|
+
/** Get screen size */
|
|
99
|
+
getScreenSize(deviceId?: string): Promise<string>;
|
|
100
|
+
/** Open a URL in the device browser */
|
|
101
|
+
openUrl(url: string, deviceId?: string): Promise<string>;
|
|
102
|
+
/** Get device orientation */
|
|
103
|
+
getOrientation(deviceId?: string): Promise<string>;
|
|
104
|
+
/** Terminate an app */
|
|
105
|
+
terminateApp(packageName: string, deviceId?: string): Promise<string>;
|
|
106
|
+
/** Double tap at coordinates */
|
|
107
|
+
doubleTap(x: number, y: number, deviceId?: string): Promise<string>;
|
|
108
|
+
/** Long press at coordinates */
|
|
109
|
+
longPress(x: number, y: number, duration?: number, deviceId?: string): Promise<string>;
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=mobile-mcp-client.d.ts.map
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-mcp-client.ts — kbot <-> mobile-mcp integration
|
|
3
|
+
*
|
|
4
|
+
* Singleton client that manages the mobile-mcp server process lifecycle.
|
|
5
|
+
* Communicates via MCP protocol over stdio transport.
|
|
6
|
+
* Auto-installs @mobilenext/mobile-mcp via npm if not present.
|
|
7
|
+
*
|
|
8
|
+
* mobile-mcp provides native accessibility-tree-based automation for
|
|
9
|
+
* iOS and Android devices connected via USB or WiFi.
|
|
10
|
+
*
|
|
11
|
+
* @see https://github.com/mobile-next/mobile-mcp
|
|
12
|
+
*/
|
|
13
|
+
import { spawn, execSync } from 'node:child_process';
|
|
14
|
+
import { Buffer } from 'node:buffer';
|
|
15
|
+
function encodeJsonRpc(msg) {
|
|
16
|
+
const body = JSON.stringify(msg);
|
|
17
|
+
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
|
|
18
|
+
}
|
|
19
|
+
// ── MobileMCPClient ────────────────────────────────────────────────────
|
|
20
|
+
export class MobileMCPClient {
|
|
21
|
+
static instance = null;
|
|
22
|
+
process = null;
|
|
23
|
+
messageId = 0;
|
|
24
|
+
pending = new Map();
|
|
25
|
+
buffer = '';
|
|
26
|
+
initialized = false;
|
|
27
|
+
activeDeviceId = null;
|
|
28
|
+
static getInstance() {
|
|
29
|
+
if (!MobileMCPClient.instance) {
|
|
30
|
+
MobileMCPClient.instance = new MobileMCPClient();
|
|
31
|
+
}
|
|
32
|
+
return MobileMCPClient.instance;
|
|
33
|
+
}
|
|
34
|
+
/** Whether the MCP server process is running and initialized */
|
|
35
|
+
get isConnected() {
|
|
36
|
+
return this.initialized && this.process !== null && !this.process.killed;
|
|
37
|
+
}
|
|
38
|
+
/** The device ID currently being controlled */
|
|
39
|
+
get currentDeviceId() {
|
|
40
|
+
return this.activeDeviceId;
|
|
41
|
+
}
|
|
42
|
+
// ── Process lifecycle ──────────────────────────────────────────────
|
|
43
|
+
/** Start the mobile-mcp server process and perform MCP handshake */
|
|
44
|
+
async start() {
|
|
45
|
+
if (this.isConnected)
|
|
46
|
+
return;
|
|
47
|
+
// Ensure npx is available
|
|
48
|
+
try {
|
|
49
|
+
execSync('which npx', { stdio: 'pipe' });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error('npx not found. Ensure Node.js >= 22 is installed.');
|
|
53
|
+
}
|
|
54
|
+
// Spawn the mobile-mcp server via npx (auto-installs if needed)
|
|
55
|
+
this.process = spawn('npx', ['-y', '@mobilenext/mobile-mcp@latest'], {
|
|
56
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
|
+
env: { ...process.env },
|
|
58
|
+
});
|
|
59
|
+
this.buffer = '';
|
|
60
|
+
this.messageId = 0;
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
this.process.stdout?.on('data', (chunk) => {
|
|
63
|
+
this.buffer += chunk.toString();
|
|
64
|
+
this.parseMessages();
|
|
65
|
+
});
|
|
66
|
+
// Log stderr for debugging but don't crash
|
|
67
|
+
this.process.stderr?.on('data', (chunk) => {
|
|
68
|
+
const msg = chunk.toString().trim();
|
|
69
|
+
if (msg && process.env.KBOT_DEBUG) {
|
|
70
|
+
console.error(`[mobile-mcp stderr] ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
this.process.on('error', (err) => {
|
|
74
|
+
this.initialized = false;
|
|
75
|
+
this.process = null;
|
|
76
|
+
if (process.env.KBOT_DEBUG) {
|
|
77
|
+
console.error(`[mobile-mcp] Process error: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
this.process.on('exit', (code) => {
|
|
81
|
+
this.initialized = false;
|
|
82
|
+
this.process = null;
|
|
83
|
+
// Reject any pending requests
|
|
84
|
+
const pendingEntries = Array.from(this.pending.entries());
|
|
85
|
+
for (const [id, { reject }] of pendingEntries) {
|
|
86
|
+
reject(new Error(`mobile-mcp process exited with code ${code}`));
|
|
87
|
+
this.pending.delete(id);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// MCP initialize handshake
|
|
91
|
+
try {
|
|
92
|
+
await this.sendRequest('initialize', {
|
|
93
|
+
protocolVersion: '2024-11-05',
|
|
94
|
+
capabilities: {},
|
|
95
|
+
clientInfo: { name: 'kbot', version: '3.61.0' },
|
|
96
|
+
});
|
|
97
|
+
this.sendNotification('initialized', {});
|
|
98
|
+
this.initialized = true;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
this.stop();
|
|
102
|
+
throw new Error(`mobile-mcp handshake failed: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
103
|
+
'Ensure @mobilenext/mobile-mcp is installed: npm install -g @mobilenext/mobile-mcp');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Stop the mobile-mcp server process */
|
|
107
|
+
stop() {
|
|
108
|
+
if (this.process) {
|
|
109
|
+
try {
|
|
110
|
+
// Graceful shutdown
|
|
111
|
+
this.sendNotification('exit', null);
|
|
112
|
+
}
|
|
113
|
+
catch { /* best effort */ }
|
|
114
|
+
this.process.kill();
|
|
115
|
+
this.process = null;
|
|
116
|
+
}
|
|
117
|
+
this.initialized = false;
|
|
118
|
+
this.activeDeviceId = null;
|
|
119
|
+
this.buffer = '';
|
|
120
|
+
this.pending.clear();
|
|
121
|
+
}
|
|
122
|
+
// ── MCP protocol ───────────────────────────────────────────────────
|
|
123
|
+
parseMessages() {
|
|
124
|
+
while (true) {
|
|
125
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
126
|
+
if (headerEnd === -1)
|
|
127
|
+
break;
|
|
128
|
+
const header = this.buffer.slice(0, headerEnd);
|
|
129
|
+
const lengthMatch = header.match(/Content-Length:\s*(\d+)/i);
|
|
130
|
+
if (!lengthMatch) {
|
|
131
|
+
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const contentLength = parseInt(lengthMatch[1], 10);
|
|
135
|
+
const bodyStart = headerEnd + 4;
|
|
136
|
+
if (this.buffer.length < bodyStart + contentLength)
|
|
137
|
+
break;
|
|
138
|
+
const body = this.buffer.slice(bodyStart, bodyStart + contentLength);
|
|
139
|
+
this.buffer = this.buffer.slice(bodyStart + contentLength);
|
|
140
|
+
try {
|
|
141
|
+
const msg = JSON.parse(body);
|
|
142
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
143
|
+
const { resolve, reject } = this.pending.get(msg.id);
|
|
144
|
+
this.pending.delete(msg.id);
|
|
145
|
+
if (msg.error) {
|
|
146
|
+
reject(new Error(msg.error.message));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
resolve(msg.result);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Skip malformed messages
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
sendRequest(method, params, timeout = 30_000) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
if (!this.process?.stdin?.writable) {
|
|
161
|
+
reject(new Error('mobile-mcp process is not running. Call mobile_connect first.'));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const id = ++this.messageId;
|
|
165
|
+
this.pending.set(id, { resolve, reject });
|
|
166
|
+
const msg = { jsonrpc: '2.0', id, method, params };
|
|
167
|
+
this.process.stdin.write(encodeJsonRpc(msg));
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (this.pending.has(id)) {
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
reject(new Error(`mobile-mcp request timeout after ${timeout / 1000}s: ${method}`));
|
|
172
|
+
}
|
|
173
|
+
}, timeout);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
sendNotification(method, params) {
|
|
177
|
+
if (!this.process?.stdin?.writable)
|
|
178
|
+
return;
|
|
179
|
+
const msg = { jsonrpc: '2.0', method, params };
|
|
180
|
+
this.process.stdin.write(encodeJsonRpc(msg));
|
|
181
|
+
}
|
|
182
|
+
/** Call a tool on the mobile-mcp server */
|
|
183
|
+
async callTool(toolName, args) {
|
|
184
|
+
if (!this.isConnected) {
|
|
185
|
+
throw new Error('Not connected to mobile-mcp. Call mobile_connect first.');
|
|
186
|
+
}
|
|
187
|
+
const result = await this.sendRequest('tools/call', {
|
|
188
|
+
name: toolName,
|
|
189
|
+
arguments: args,
|
|
190
|
+
}, 60_000);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
/** Extract text content from an MCP tool result */
|
|
194
|
+
extractText(result) {
|
|
195
|
+
const r = result;
|
|
196
|
+
if (r?.content) {
|
|
197
|
+
return r.content
|
|
198
|
+
.filter(c => c.type === 'text' && c.text)
|
|
199
|
+
.map(c => c.text)
|
|
200
|
+
.join('\n');
|
|
201
|
+
}
|
|
202
|
+
return JSON.stringify(result, null, 2);
|
|
203
|
+
}
|
|
204
|
+
/** Extract image content (base64) from an MCP tool result */
|
|
205
|
+
extractImage(result) {
|
|
206
|
+
const r = result;
|
|
207
|
+
if (r?.content) {
|
|
208
|
+
const img = r.content.find(c => c.type === 'image' && c.data);
|
|
209
|
+
if (img)
|
|
210
|
+
return { data: img.data, mimeType: img.mimeType || 'image/png' };
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
// ── High-level device operations ───────────────────────────────────
|
|
215
|
+
/** List all available devices */
|
|
216
|
+
async listDevices() {
|
|
217
|
+
const result = await this.callTool('mobile_list_available_devices', {});
|
|
218
|
+
const text = this.extractText(result);
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(text);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Try to parse from structured output
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Set the active device for subsequent operations */
|
|
228
|
+
setActiveDevice(deviceId) {
|
|
229
|
+
this.activeDeviceId = deviceId;
|
|
230
|
+
}
|
|
231
|
+
/** Get the active device ID, throwing if none set */
|
|
232
|
+
requireDevice(deviceId) {
|
|
233
|
+
const id = deviceId || this.activeDeviceId;
|
|
234
|
+
if (!id) {
|
|
235
|
+
throw new Error('No device selected. Use mobile_connect to connect to a device, or pass a device ID.');
|
|
236
|
+
}
|
|
237
|
+
return id;
|
|
238
|
+
}
|
|
239
|
+
/** List apps on the active device */
|
|
240
|
+
async listApps(deviceId) {
|
|
241
|
+
const device = this.requireDevice(deviceId);
|
|
242
|
+
const result = await this.callTool('mobile_list_apps', { device });
|
|
243
|
+
return this.extractText(result);
|
|
244
|
+
}
|
|
245
|
+
/** Launch an app by bundle ID */
|
|
246
|
+
async launchApp(packageName, deviceId) {
|
|
247
|
+
const device = this.requireDevice(deviceId);
|
|
248
|
+
const result = await this.callTool('mobile_launch_app', { device, packageName });
|
|
249
|
+
return this.extractText(result);
|
|
250
|
+
}
|
|
251
|
+
/** Take a screenshot, returns base64 image data */
|
|
252
|
+
async takeScreenshot(deviceId) {
|
|
253
|
+
const device = this.requireDevice(deviceId);
|
|
254
|
+
const result = await this.callTool('mobile_take_screenshot', { device });
|
|
255
|
+
const img = this.extractImage(result);
|
|
256
|
+
if (img)
|
|
257
|
+
return img;
|
|
258
|
+
return this.extractText(result);
|
|
259
|
+
}
|
|
260
|
+
/** Save screenshot to a file */
|
|
261
|
+
async saveScreenshot(saveTo, deviceId) {
|
|
262
|
+
const device = this.requireDevice(deviceId);
|
|
263
|
+
const result = await this.callTool('mobile_save_screenshot', { device, saveTo });
|
|
264
|
+
return this.extractText(result);
|
|
265
|
+
}
|
|
266
|
+
/** List UI elements on screen via accessibility tree */
|
|
267
|
+
async listElements(deviceId) {
|
|
268
|
+
const device = this.requireDevice(deviceId);
|
|
269
|
+
const result = await this.callTool('mobile_list_elements_on_screen', { device });
|
|
270
|
+
return this.extractText(result);
|
|
271
|
+
}
|
|
272
|
+
/** Tap at coordinates */
|
|
273
|
+
async tap(x, y, deviceId) {
|
|
274
|
+
const device = this.requireDevice(deviceId);
|
|
275
|
+
const result = await this.callTool('mobile_click_on_screen_at_coordinates', { device, x, y });
|
|
276
|
+
return this.extractText(result);
|
|
277
|
+
}
|
|
278
|
+
/** Swipe on screen */
|
|
279
|
+
async swipe(direction, opts) {
|
|
280
|
+
const device = this.requireDevice(opts?.deviceId);
|
|
281
|
+
const args = { device, direction };
|
|
282
|
+
if (opts?.x !== undefined)
|
|
283
|
+
args.x = opts.x;
|
|
284
|
+
if (opts?.y !== undefined)
|
|
285
|
+
args.y = opts.y;
|
|
286
|
+
if (opts?.distance !== undefined)
|
|
287
|
+
args.distance = opts.distance;
|
|
288
|
+
const result = await this.callTool('mobile_swipe_on_screen', args);
|
|
289
|
+
return this.extractText(result);
|
|
290
|
+
}
|
|
291
|
+
/** Type text */
|
|
292
|
+
async typeText(text, submit = false, deviceId) {
|
|
293
|
+
const device = this.requireDevice(deviceId);
|
|
294
|
+
const result = await this.callTool('mobile_type_keys', { device, text, submit });
|
|
295
|
+
return this.extractText(result);
|
|
296
|
+
}
|
|
297
|
+
/** Press a device button */
|
|
298
|
+
async pressButton(button, deviceId) {
|
|
299
|
+
const device = this.requireDevice(deviceId);
|
|
300
|
+
const result = await this.callTool('mobile_press_button', { device, button });
|
|
301
|
+
return this.extractText(result);
|
|
302
|
+
}
|
|
303
|
+
/** Get screen size */
|
|
304
|
+
async getScreenSize(deviceId) {
|
|
305
|
+
const device = this.requireDevice(deviceId);
|
|
306
|
+
const result = await this.callTool('mobile_get_screen_size', { device });
|
|
307
|
+
return this.extractText(result);
|
|
308
|
+
}
|
|
309
|
+
/** Open a URL in the device browser */
|
|
310
|
+
async openUrl(url, deviceId) {
|
|
311
|
+
const device = this.requireDevice(deviceId);
|
|
312
|
+
const result = await this.callTool('mobile_open_url', { device, url });
|
|
313
|
+
return this.extractText(result);
|
|
314
|
+
}
|
|
315
|
+
/** Get device orientation */
|
|
316
|
+
async getOrientation(deviceId) {
|
|
317
|
+
const device = this.requireDevice(deviceId);
|
|
318
|
+
const result = await this.callTool('mobile_get_orientation', { device });
|
|
319
|
+
return this.extractText(result);
|
|
320
|
+
}
|
|
321
|
+
/** Terminate an app */
|
|
322
|
+
async terminateApp(packageName, deviceId) {
|
|
323
|
+
const device = this.requireDevice(deviceId);
|
|
324
|
+
const result = await this.callTool('mobile_terminate_app', { device, packageName });
|
|
325
|
+
return this.extractText(result);
|
|
326
|
+
}
|
|
327
|
+
/** Double tap at coordinates */
|
|
328
|
+
async doubleTap(x, y, deviceId) {
|
|
329
|
+
const device = this.requireDevice(deviceId);
|
|
330
|
+
const result = await this.callTool('mobile_double_tap_on_screen', { device, x, y });
|
|
331
|
+
return this.extractText(result);
|
|
332
|
+
}
|
|
333
|
+
/** Long press at coordinates */
|
|
334
|
+
async longPress(x, y, duration, deviceId) {
|
|
335
|
+
const device = this.requireDevice(deviceId);
|
|
336
|
+
const args = { device, x, y };
|
|
337
|
+
if (duration !== undefined)
|
|
338
|
+
args.duration = duration;
|
|
339
|
+
const result = await this.callTool('mobile_long_press_on_screen_at_coordinates', args);
|
|
340
|
+
return this.extractText(result);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
//# sourceMappingURL=mobile-mcp-client.js.map
|