@hypothesi/tauri-mcp-server 0.5.1 → 0.6.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/README.md +43 -1
- package/dist/driver/plugin-client.js +8 -10
- package/dist/driver/plugin-commands.js +43 -28
- package/dist/driver/session-manager.js +256 -117
- package/dist/driver/webview-executor.js +21 -18
- package/dist/driver/webview-interactions.js +26 -25
- package/dist/monitor/logs.js +3 -2
- package/dist/tools-registry.js +36 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@hypothesi/tauri-mcp-server)
|
|
4
4
|
[](https://github.com/hypothesi/mcp-server-tauri/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
A **Model Context Protocol (MCP) server** that enables AI assistants like Claude, Cursor, and Windsurf to build, test, and debug Tauri v2 applications.
|
|
6
|
+
A **Model Context Protocol (MCP) server** that enables AI assistants like Claude, Cursor, and Windsurf to build, test, and debug Tauri® v2 applications.
|
|
7
7
|
|
|
8
8
|
📖 **[Full Documentation](https://hypothesi.github.io/mcp-server-tauri)**
|
|
9
9
|
|
|
@@ -52,6 +52,40 @@ npx -y install-mcp @hypothesi/tauri-mcp-server --client claude-code
|
|
|
52
52
|
|
|
53
53
|
Supported clients: `claude-code`, `cursor`, `windsurf`, `vscode`, `cline`, `roo-cline`, `claude`, `zed`, `goose`, `warp`, `codex`
|
|
54
54
|
|
|
55
|
+
## Multi-App Support
|
|
56
|
+
|
|
57
|
+
The MCP server supports connecting to multiple Tauri apps simultaneously. Each app runs on a unique port, and the most recently connected app becomes the "default" app.
|
|
58
|
+
|
|
59
|
+
**Key Features:**
|
|
60
|
+
- Connect to multiple apps on different ports
|
|
61
|
+
- Default app used when no `appIdentifier` specified
|
|
62
|
+
- Target specific apps using port number or bundle ID
|
|
63
|
+
- Stop individual sessions or all sessions at once
|
|
64
|
+
|
|
65
|
+
**Example Usage:**
|
|
66
|
+
```javascript
|
|
67
|
+
// Connect to first app
|
|
68
|
+
await tauri_driver_session({ action: "start", port: 9223 })
|
|
69
|
+
|
|
70
|
+
// Connect to second app
|
|
71
|
+
await tauri_driver_session({ action: "start", port: 9224 })
|
|
72
|
+
|
|
73
|
+
// Check status - shows both apps with default indicator
|
|
74
|
+
await tauri_driver_session({ action: "status" })
|
|
75
|
+
|
|
76
|
+
// Use default app (most recent - port 9224)
|
|
77
|
+
await tauri_webview_screenshot()
|
|
78
|
+
|
|
79
|
+
// Target specific app by port
|
|
80
|
+
await tauri_webview_screenshot({ appIdentifier: 9223 })
|
|
81
|
+
|
|
82
|
+
// Stop specific app
|
|
83
|
+
await tauri_driver_session({ action: "stop", appIdentifier: 9223 })
|
|
84
|
+
|
|
85
|
+
// Stop all apps
|
|
86
|
+
await tauri_driver_session({ action: "stop" })
|
|
87
|
+
```
|
|
88
|
+
|
|
55
89
|
## Available Tools (16 total)
|
|
56
90
|
|
|
57
91
|
### UI Automation
|
|
@@ -95,3 +129,11 @@ Supported clients: `claude-code`, `cursor`, `windsurf`, `vscode`, `cline`, `roo-
|
|
|
95
129
|
## License
|
|
96
130
|
|
|
97
131
|
MIT © [hypothesi](https://github.com/hypothesi)
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Trademark Notice
|
|
136
|
+
|
|
137
|
+
TAURI® is a registered trademark of The Tauri Programme within the Commons Conservancy. [https://tauri.app/](https://tauri.app/)
|
|
138
|
+
|
|
139
|
+
This project is not affiliated with, endorsed by, or sponsored by The Tauri Programme within the Commons Conservancy.
|
|
@@ -225,20 +225,18 @@ export async function connectPlugin(host, port) {
|
|
|
225
225
|
* Ensures a session is active and connects to the plugin using session config.
|
|
226
226
|
* This should be used by all tools that require a connected Tauri app.
|
|
227
227
|
*
|
|
228
|
+
* @param appIdentifier - Optional app identifier to target specific app
|
|
228
229
|
* @throws Error if no session is active
|
|
229
230
|
*/
|
|
230
|
-
export async function ensureSessionAndConnect() {
|
|
231
|
+
export async function ensureSessionAndConnect(appIdentifier) {
|
|
231
232
|
// Import dynamically to avoid circular dependency
|
|
232
|
-
const {
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
const { resolveTargetApp } = await import('./session-manager.js');
|
|
234
|
+
const session = resolveTargetApp(appIdentifier);
|
|
235
|
+
// Ensure client is connected
|
|
236
|
+
if (!session.client.isConnected()) {
|
|
237
|
+
await session.client.connect();
|
|
235
238
|
}
|
|
236
|
-
|
|
237
|
-
if (!session) {
|
|
238
|
-
throw new Error('Session state is inconsistent. Please restart the session.');
|
|
239
|
-
}
|
|
240
|
-
await connectPlugin(session.host, session.port);
|
|
241
|
-
return getPluginClient(session.host, session.port);
|
|
239
|
+
return session.client;
|
|
242
240
|
}
|
|
243
241
|
export async function disconnectPlugin() {
|
|
244
242
|
const client = getPluginClient();
|
|
@@ -3,11 +3,13 @@ import { ensureSessionAndConnect, getExistingPluginClient } from './plugin-clien
|
|
|
3
3
|
export const ExecuteIPCCommandSchema = z.object({
|
|
4
4
|
command: z.string(),
|
|
5
5
|
args: z.unknown().optional(),
|
|
6
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
6
7
|
});
|
|
7
|
-
export async function executeIPCCommand(
|
|
8
|
+
export async function executeIPCCommand(options) {
|
|
8
9
|
try {
|
|
10
|
+
const { command, args = {}, appIdentifier } = options;
|
|
9
11
|
// Ensure we have an active session and are connected
|
|
10
|
-
const client = await ensureSessionAndConnect();
|
|
12
|
+
const client = await ensureSessionAndConnect(appIdentifier);
|
|
11
13
|
// Send IPC command via WebSocket to the mcp-bridge plugin
|
|
12
14
|
const response = await client.sendCommand({
|
|
13
15
|
command: 'invoke_tauri',
|
|
@@ -26,19 +28,20 @@ export async function executeIPCCommand(command, args = {}) {
|
|
|
26
28
|
// Combined schema for managing IPC monitoring
|
|
27
29
|
export const ManageIPCMonitoringSchema = z.object({
|
|
28
30
|
action: z.enum(['start', 'stop']).describe('Action to perform: start or stop IPC monitoring'),
|
|
31
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
29
32
|
});
|
|
30
33
|
// Keep individual schemas for backward compatibility if needed
|
|
31
34
|
export const StartIPCMonitoringSchema = z.object({});
|
|
32
35
|
export const StopIPCMonitoringSchema = z.object({});
|
|
33
|
-
export async function manageIPCMonitoring(action) {
|
|
36
|
+
export async function manageIPCMonitoring(action, appIdentifier) {
|
|
34
37
|
if (action === 'start') {
|
|
35
|
-
return startIPCMonitoring();
|
|
38
|
+
return startIPCMonitoring(appIdentifier);
|
|
36
39
|
}
|
|
37
|
-
return stopIPCMonitoring();
|
|
40
|
+
return stopIPCMonitoring(appIdentifier);
|
|
38
41
|
}
|
|
39
|
-
export async function startIPCMonitoring() {
|
|
42
|
+
export async function startIPCMonitoring(appIdentifier) {
|
|
40
43
|
try {
|
|
41
|
-
const result = await executeIPCCommand('plugin:mcp-bridge|start_ipc_monitor');
|
|
44
|
+
const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|start_ipc_monitor', appIdentifier });
|
|
42
45
|
const parsed = JSON.parse(result);
|
|
43
46
|
if (!parsed.success) {
|
|
44
47
|
throw new Error(parsed.error || 'Unknown error');
|
|
@@ -50,9 +53,9 @@ export async function startIPCMonitoring() {
|
|
|
50
53
|
throw new Error(`Failed to start IPC monitoring: ${message}`);
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
export async function stopIPCMonitoring() {
|
|
56
|
+
export async function stopIPCMonitoring(appIdentifier) {
|
|
54
57
|
try {
|
|
55
|
-
const result = await executeIPCCommand('plugin:mcp-bridge|stop_ipc_monitor');
|
|
58
|
+
const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|stop_ipc_monitor', appIdentifier });
|
|
56
59
|
const parsed = JSON.parse(result);
|
|
57
60
|
if (!parsed.success) {
|
|
58
61
|
throw new Error(parsed.error || 'Unknown error');
|
|
@@ -66,10 +69,11 @@ export async function stopIPCMonitoring() {
|
|
|
66
69
|
}
|
|
67
70
|
export const GetIPCEventsSchema = z.object({
|
|
68
71
|
filter: z.string().optional().describe('Filter events by command name'),
|
|
72
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
69
73
|
});
|
|
70
|
-
export async function getIPCEvents(filter) {
|
|
74
|
+
export async function getIPCEvents(filter, appIdentifier) {
|
|
71
75
|
try {
|
|
72
|
-
const result = await executeIPCCommand('plugin:mcp-bridge|get_ipc_events');
|
|
76
|
+
const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_ipc_events', appIdentifier });
|
|
73
77
|
const parsed = JSON.parse(result);
|
|
74
78
|
if (!parsed.success) {
|
|
75
79
|
throw new Error(parsed.error || 'Unknown error');
|
|
@@ -91,12 +95,17 @@ export async function getIPCEvents(filter) {
|
|
|
91
95
|
export const EmitTestEventSchema = z.object({
|
|
92
96
|
eventName: z.string(),
|
|
93
97
|
payload: z.unknown(),
|
|
98
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
94
99
|
});
|
|
95
|
-
export async function emitTestEvent(eventName, payload) {
|
|
100
|
+
export async function emitTestEvent(eventName, payload, appIdentifier) {
|
|
96
101
|
try {
|
|
97
|
-
const result = await executeIPCCommand(
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
const result = await executeIPCCommand({
|
|
103
|
+
command: 'plugin:mcp-bridge|emit_event',
|
|
104
|
+
args: {
|
|
105
|
+
eventName,
|
|
106
|
+
payload,
|
|
107
|
+
},
|
|
108
|
+
appIdentifier,
|
|
100
109
|
});
|
|
101
110
|
const parsed = JSON.parse(result);
|
|
102
111
|
if (!parsed.success) {
|
|
@@ -109,10 +118,12 @@ export async function emitTestEvent(eventName, payload) {
|
|
|
109
118
|
throw new Error(`Failed to emit event: ${message}`);
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
|
-
export const GetWindowInfoSchema = z.object({
|
|
113
|
-
|
|
121
|
+
export const GetWindowInfoSchema = z.object({
|
|
122
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
123
|
+
});
|
|
124
|
+
export async function getWindowInfo(appIdentifier) {
|
|
114
125
|
try {
|
|
115
|
-
const result = await executeIPCCommand('plugin:mcp-bridge|get_window_info');
|
|
126
|
+
const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_window_info', appIdentifier });
|
|
116
127
|
const parsed = JSON.parse(result);
|
|
117
128
|
if (!parsed.success) {
|
|
118
129
|
throw new Error(parsed.error || 'Unknown error');
|
|
@@ -124,7 +135,9 @@ export async function getWindowInfo() {
|
|
|
124
135
|
throw new Error(`Failed to get window info: ${message}`);
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
|
-
export const GetBackendStateSchema = z.object({
|
|
138
|
+
export const GetBackendStateSchema = z.object({
|
|
139
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
140
|
+
});
|
|
128
141
|
/**
|
|
129
142
|
* Get backend state from the connected Tauri app.
|
|
130
143
|
*
|
|
@@ -135,8 +148,9 @@ export const GetBackendStateSchema = z.object({});
|
|
|
135
148
|
* @param useExistingClient If true, uses the existing connected client without
|
|
136
149
|
* session validation. Used during session setup before currentSession is set.
|
|
137
150
|
*/
|
|
138
|
-
export async function getBackendState(
|
|
151
|
+
export async function getBackendState(options = {}) {
|
|
139
152
|
try {
|
|
153
|
+
const { useExistingClient = false, appIdentifier } = options;
|
|
140
154
|
if (useExistingClient) {
|
|
141
155
|
// During session setup, use the already-connected client directly
|
|
142
156
|
const client = getExistingPluginClient();
|
|
@@ -153,7 +167,7 @@ export async function getBackendState(useExistingClient = false) {
|
|
|
153
167
|
return JSON.stringify(response.data);
|
|
154
168
|
}
|
|
155
169
|
// Normal mode: use executeIPCCommand which validates session
|
|
156
|
-
const result = await executeIPCCommand('plugin:mcp-bridge|get_backend_state');
|
|
170
|
+
const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_backend_state', appIdentifier });
|
|
157
171
|
const parsed = JSON.parse(result);
|
|
158
172
|
if (!parsed.success) {
|
|
159
173
|
throw new Error(parsed.error || 'Unknown error');
|
|
@@ -172,9 +186,9 @@ export const ListWindowsSchema = z.object({});
|
|
|
172
186
|
/**
|
|
173
187
|
* Lists all open webview windows in the Tauri application.
|
|
174
188
|
*/
|
|
175
|
-
export async function listWindows() {
|
|
189
|
+
export async function listWindows(appIdentifier) {
|
|
176
190
|
try {
|
|
177
|
-
const client = await ensureSessionAndConnect();
|
|
191
|
+
const client = await ensureSessionAndConnect(appIdentifier);
|
|
178
192
|
const response = await client.sendCommand({
|
|
179
193
|
command: 'list_windows',
|
|
180
194
|
});
|
|
@@ -208,7 +222,7 @@ export const ResizeWindowSchema = z.object({
|
|
|
208
222
|
*/
|
|
209
223
|
export async function resizeWindow(options) {
|
|
210
224
|
try {
|
|
211
|
-
const client = await ensureSessionAndConnect();
|
|
225
|
+
const client = await ensureSessionAndConnect(options.appIdentifier);
|
|
212
226
|
const response = await client.sendCommand({
|
|
213
227
|
command: 'resize_window',
|
|
214
228
|
args: {
|
|
@@ -239,6 +253,7 @@ export const ManageWindowSchema = z.object({
|
|
|
239
253
|
.describe('Height in pixels (required for "resize" action)'),
|
|
240
254
|
logical: z.boolean().optional().default(true)
|
|
241
255
|
.describe('Use logical pixels (true, default) or physical pixels (false). Only for "resize"'),
|
|
256
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
242
257
|
});
|
|
243
258
|
/**
|
|
244
259
|
* Unified window management function.
|
|
@@ -252,14 +267,14 @@ export const ManageWindowSchema = z.object({
|
|
|
252
267
|
* @returns JSON string with the result
|
|
253
268
|
*/
|
|
254
269
|
export async function manageWindow(options) {
|
|
255
|
-
const { action, windowId, width, height, logical } = options;
|
|
270
|
+
const { action, windowId, width, height, logical, appIdentifier } = options;
|
|
256
271
|
switch (action) {
|
|
257
272
|
case 'list': {
|
|
258
|
-
return listWindows();
|
|
273
|
+
return listWindows(appIdentifier);
|
|
259
274
|
}
|
|
260
275
|
case 'info': {
|
|
261
276
|
try {
|
|
262
|
-
const client = await ensureSessionAndConnect();
|
|
277
|
+
const client = await ensureSessionAndConnect(appIdentifier);
|
|
263
278
|
const response = await client.sendCommand({
|
|
264
279
|
command: 'get_window_info',
|
|
265
280
|
args: { windowId },
|
|
@@ -278,7 +293,7 @@ export async function manageWindow(options) {
|
|
|
278
293
|
if (width === undefined || height === undefined) {
|
|
279
294
|
throw new Error('width and height are required for resize action');
|
|
280
295
|
}
|
|
281
|
-
return resizeWindow({ width, height, windowId, logical });
|
|
296
|
+
return resizeWindow({ width, height, windowId, logical, appIdentifier });
|
|
282
297
|
}
|
|
283
298
|
default: {
|
|
284
299
|
throw new Error(`Unknown action: ${action}`);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { getDefaultHost, getDefaultPort } from '../config.js';
|
|
3
3
|
import { AppDiscovery } from './app-discovery.js';
|
|
4
|
-
import {
|
|
5
|
-
import { getBackendState } from './plugin-commands.js';
|
|
4
|
+
import { PluginClient } from './plugin-client.js';
|
|
6
5
|
import { resetInitialization } from './webview-executor.js';
|
|
6
|
+
import { createMcpLogger } from '../logger.js';
|
|
7
|
+
const sessionLogger = createMcpLogger('SESSION');
|
|
7
8
|
/**
|
|
8
9
|
* Session Manager - Native IPC-based session management
|
|
9
10
|
*
|
|
@@ -22,25 +23,45 @@ export const ManageDriverSessionSchema = z.object({
|
|
|
22
23
|
action: z.enum(['start', 'stop', 'status']).describe('Action to perform: start or stop the session, or check status'),
|
|
23
24
|
host: z.string().optional().describe('Host address to connect to (e.g., 192.168.1.100). Falls back to MCP_BRIDGE_HOST or TAURI_DEV_HOST env vars'),
|
|
24
25
|
port: z.number().optional().describe('Port to connect to (default: 9223)'),
|
|
26
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App identifier (port number or bundle ID) to stop. Only used with action "stop". If omitted, stops all sessions.'),
|
|
25
27
|
});
|
|
26
28
|
// ============================================================================
|
|
27
29
|
// Module State
|
|
28
30
|
// ============================================================================
|
|
29
31
|
// AppDiscovery instance - recreated when host changes
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
let appDiscovery = null;
|
|
33
|
+
// Track multiple concurrent sessions keyed by port
|
|
34
|
+
const activeSessions = new Map();
|
|
35
|
+
// Track the default app (most recently connected)
|
|
36
|
+
let defaultPort = null;
|
|
32
37
|
/**
|
|
33
|
-
* Check if
|
|
34
|
-
* @returns true if
|
|
38
|
+
* Check if any session is currently active.
|
|
39
|
+
* @returns true if at least one session exists
|
|
35
40
|
*/
|
|
36
41
|
export function hasActiveSession() {
|
|
37
|
-
return
|
|
42
|
+
return activeSessions.size > 0;
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
|
-
* Get
|
|
45
|
+
* Get a specific session by port.
|
|
41
46
|
*/
|
|
42
|
-
export function
|
|
43
|
-
return
|
|
47
|
+
export function getSession(port) {
|
|
48
|
+
return activeSessions.get(port) ?? null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get the default session (most recently connected).
|
|
52
|
+
*/
|
|
53
|
+
export function getDefaultSession() {
|
|
54
|
+
if (defaultPort !== null && activeSessions.has(defaultPort)) {
|
|
55
|
+
const session = activeSessions.get(defaultPort);
|
|
56
|
+
return session ?? null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get all active sessions.
|
|
62
|
+
*/
|
|
63
|
+
export function getAllSessions() {
|
|
64
|
+
return Array.from(activeSessions.values());
|
|
44
65
|
}
|
|
45
66
|
function getAppDiscovery(host) {
|
|
46
67
|
if (!appDiscovery || appDiscovery.host !== host) {
|
|
@@ -48,6 +69,208 @@ function getAppDiscovery(host) {
|
|
|
48
69
|
}
|
|
49
70
|
return appDiscovery;
|
|
50
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve target app from port or identifier.
|
|
74
|
+
* Returns the appropriate session based on the routing logic.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveTargetApp(portOrIdentifier) {
|
|
77
|
+
if (activeSessions.size === 0) {
|
|
78
|
+
throw new Error('No active session. Call tauri_driver_session with action "start" first to connect to a Tauri app.');
|
|
79
|
+
}
|
|
80
|
+
// Single app - return it
|
|
81
|
+
if (activeSessions.size === 1) {
|
|
82
|
+
const session = activeSessions.values().next().value;
|
|
83
|
+
if (!session) {
|
|
84
|
+
throw new Error('Session state inconsistent');
|
|
85
|
+
}
|
|
86
|
+
return session;
|
|
87
|
+
}
|
|
88
|
+
// Multiple apps - need identifier or use default
|
|
89
|
+
if (portOrIdentifier !== undefined) {
|
|
90
|
+
// Try port lookup first
|
|
91
|
+
const port = typeof portOrIdentifier === 'number'
|
|
92
|
+
? portOrIdentifier
|
|
93
|
+
: parseInt(String(portOrIdentifier), 10);
|
|
94
|
+
if (!isNaN(port) && activeSessions.has(port)) {
|
|
95
|
+
const session = activeSessions.get(port);
|
|
96
|
+
if (session) {
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Try identifier match
|
|
101
|
+
for (const session of activeSessions.values()) {
|
|
102
|
+
if (session.identifier === String(portOrIdentifier)) {
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error(formatAppNotFoundError(portOrIdentifier));
|
|
107
|
+
}
|
|
108
|
+
// Use default app
|
|
109
|
+
if (defaultPort !== null && activeSessions.has(defaultPort)) {
|
|
110
|
+
const session = activeSessions.get(defaultPort);
|
|
111
|
+
if (session) {
|
|
112
|
+
return session;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw new Error('No default app set. This should not happen.');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Format error message when app not found.
|
|
119
|
+
*/
|
|
120
|
+
function formatAppNotFoundError(identifier) {
|
|
121
|
+
const appList = Array.from(activeSessions.values())
|
|
122
|
+
.map((session) => {
|
|
123
|
+
const isDefault = session.port === defaultPort;
|
|
124
|
+
const defaultMarker = isDefault ? ' [DEFAULT]' : '';
|
|
125
|
+
return ` - ${session.port}: ${session.identifier || 'unknown'} (${session.host}:${session.port})${defaultMarker}`;
|
|
126
|
+
})
|
|
127
|
+
.join('\n');
|
|
128
|
+
return (`App "${identifier}" not found.\n\n` +
|
|
129
|
+
`Connected apps:\n${appList}\n\n` +
|
|
130
|
+
'Use tauri_driver_session with action "status" to list all connected apps.');
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Promote the next default app when the current default is removed.
|
|
134
|
+
* Selects the oldest remaining session (first in insertion order).
|
|
135
|
+
*/
|
|
136
|
+
function promoteNextDefault() {
|
|
137
|
+
if (activeSessions.size > 0) {
|
|
138
|
+
// Get first session (oldest)
|
|
139
|
+
const firstSession = activeSessions.values().next().value;
|
|
140
|
+
if (firstSession) {
|
|
141
|
+
defaultPort = firstSession.port;
|
|
142
|
+
sessionLogger.info(`Promoted port ${defaultPort} as new default app`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
defaultPort = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
defaultPort = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function handleStatusAction() {
|
|
153
|
+
if (activeSessions.size === 0) {
|
|
154
|
+
return JSON.stringify({
|
|
155
|
+
connected: false,
|
|
156
|
+
app: null,
|
|
157
|
+
identifier: null,
|
|
158
|
+
host: null,
|
|
159
|
+
port: null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (activeSessions.size === 1) {
|
|
163
|
+
const session = activeSessions.values().next().value;
|
|
164
|
+
if (!session) {
|
|
165
|
+
return JSON.stringify({
|
|
166
|
+
connected: false,
|
|
167
|
+
app: null,
|
|
168
|
+
identifier: null,
|
|
169
|
+
host: null,
|
|
170
|
+
port: null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return JSON.stringify({
|
|
174
|
+
connected: true,
|
|
175
|
+
app: session.name,
|
|
176
|
+
identifier: session.identifier,
|
|
177
|
+
host: session.host,
|
|
178
|
+
port: session.port,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const apps = Array.from(activeSessions.values()).map((session) => {
|
|
182
|
+
return {
|
|
183
|
+
name: session.name,
|
|
184
|
+
identifier: session.identifier,
|
|
185
|
+
host: session.host,
|
|
186
|
+
port: session.port,
|
|
187
|
+
isDefault: session.port === defaultPort,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
return JSON.stringify({
|
|
191
|
+
connected: true,
|
|
192
|
+
apps,
|
|
193
|
+
totalCount: apps.length,
|
|
194
|
+
defaultPort,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function handleStartAction(host, port) {
|
|
198
|
+
const configuredHost = host ?? getDefaultHost();
|
|
199
|
+
const configuredPort = port ?? getDefaultPort();
|
|
200
|
+
if (activeSessions.has(configuredPort)) {
|
|
201
|
+
return `Already connected to app on port ${configuredPort}`;
|
|
202
|
+
}
|
|
203
|
+
let connectedSession = null;
|
|
204
|
+
if (configuredHost !== 'localhost' && configuredHost !== '127.0.0.1') {
|
|
205
|
+
try {
|
|
206
|
+
connectedSession = await tryConnect('localhost', configuredPort);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// ignore
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!connectedSession) {
|
|
213
|
+
try {
|
|
214
|
+
connectedSession = await tryConnect(configuredHost, configuredPort);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// ignore
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!connectedSession) {
|
|
221
|
+
const localhostDiscovery = getAppDiscovery('localhost');
|
|
222
|
+
const firstApp = await localhostDiscovery.getFirstAvailableApp();
|
|
223
|
+
if (firstApp) {
|
|
224
|
+
try {
|
|
225
|
+
connectedSession = await tryConnect('localhost', firstApp.port);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// ignore
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!connectedSession) {
|
|
233
|
+
return `Session start failed - no Tauri app found at localhost or ${configuredHost}:${configuredPort}`;
|
|
234
|
+
}
|
|
235
|
+
const client = new PluginClient(connectedSession.host, connectedSession.port);
|
|
236
|
+
await client.connect();
|
|
237
|
+
const identifier = await fetchAppIdentifier(client);
|
|
238
|
+
const sessionInfo = {
|
|
239
|
+
name: connectedSession.name,
|
|
240
|
+
identifier,
|
|
241
|
+
host: connectedSession.host,
|
|
242
|
+
port: connectedSession.port,
|
|
243
|
+
client,
|
|
244
|
+
connected: true,
|
|
245
|
+
};
|
|
246
|
+
activeSessions.set(connectedSession.port, sessionInfo);
|
|
247
|
+
defaultPort = connectedSession.port;
|
|
248
|
+
sessionLogger.info(`Session started: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`);
|
|
249
|
+
return `Session started with app: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`;
|
|
250
|
+
}
|
|
251
|
+
async function handleStopAction(appIdentifier) {
|
|
252
|
+
if (appIdentifier !== undefined) {
|
|
253
|
+
const session = resolveTargetApp(appIdentifier);
|
|
254
|
+
session.client.disconnect();
|
|
255
|
+
activeSessions.delete(session.port);
|
|
256
|
+
if (session.port === defaultPort) {
|
|
257
|
+
promoteNextDefault();
|
|
258
|
+
}
|
|
259
|
+
sessionLogger.info(`Session stopped: ${session.name} (${session.host}:${session.port})`);
|
|
260
|
+
return `Session stopped: ${session.name} (${session.host}:${session.port})`;
|
|
261
|
+
}
|
|
262
|
+
for (const session of activeSessions.values()) {
|
|
263
|
+
session.client.disconnect();
|
|
264
|
+
}
|
|
265
|
+
activeSessions.clear();
|
|
266
|
+
defaultPort = null;
|
|
267
|
+
if (appDiscovery) {
|
|
268
|
+
await appDiscovery.disconnectAll();
|
|
269
|
+
}
|
|
270
|
+
resetInitialization();
|
|
271
|
+
sessionLogger.info('All sessions stopped');
|
|
272
|
+
return 'All sessions stopped';
|
|
273
|
+
}
|
|
51
274
|
// ============================================================================
|
|
52
275
|
// Session Management
|
|
53
276
|
// ============================================================================
|
|
@@ -66,18 +289,24 @@ async function tryConnect(host, port) {
|
|
|
66
289
|
}
|
|
67
290
|
/**
|
|
68
291
|
* Fetch the app identifier from the backend state.
|
|
69
|
-
* Must be called after
|
|
292
|
+
* Must be called after a PluginClient is connected.
|
|
70
293
|
*
|
|
294
|
+
* @param client - The PluginClient to query
|
|
71
295
|
* @returns The app identifier (bundle ID) or null if not available. Returns null when:
|
|
72
296
|
* - The plugin doesn't support the identifier field (older versions)
|
|
73
297
|
* - The backend state request fails
|
|
74
298
|
* - The identifier field is missing from the response
|
|
75
299
|
*/
|
|
76
|
-
async function fetchAppIdentifier() {
|
|
300
|
+
async function fetchAppIdentifier(client) {
|
|
77
301
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
302
|
+
const response = await client.sendCommand({
|
|
303
|
+
command: 'invoke_tauri',
|
|
304
|
+
args: { command: 'plugin:mcp-bridge|get_backend_state', args: {} },
|
|
305
|
+
});
|
|
306
|
+
if (!response.success || !response.data) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const state = response.data;
|
|
81
310
|
// Return null if identifier is not present (backward compat with older plugins)
|
|
82
311
|
return state.app?.identifier ?? null;
|
|
83
312
|
}
|
|
@@ -98,113 +327,23 @@ async function fetchAppIdentifier() {
|
|
|
98
327
|
* @param action - 'start', 'stop', or 'status'
|
|
99
328
|
* @param host - Optional host address (defaults to env var or localhost)
|
|
100
329
|
* @param port - Optional port number (defaults to 9223)
|
|
330
|
+
* @param appIdentifier - Optional app identifier for 'stop' action (port or bundle ID)
|
|
101
331
|
* @returns For 'start'/'stop': A message string describing the result.
|
|
102
|
-
* For 'status': A JSON string with connection details
|
|
103
|
-
* - `connected`: boolean indicating if connected
|
|
104
|
-
* - `app`: app name (or null if not connected)
|
|
105
|
-
* - `identifier`: app bundle ID (e.g., "com.example.app"), or null
|
|
106
|
-
* - `host`: connected host (or null)
|
|
107
|
-
* - `port`: connected port (or null)
|
|
332
|
+
* For 'status': A JSON string with connection details
|
|
108
333
|
*/
|
|
109
|
-
export async function manageDriverSession(action, host, port) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (client?.isConnected() && currentSession) {
|
|
114
|
-
return JSON.stringify({
|
|
115
|
-
connected: true,
|
|
116
|
-
app: currentSession.name,
|
|
117
|
-
identifier: currentSession.identifier,
|
|
118
|
-
host: currentSession.host,
|
|
119
|
-
port: currentSession.port,
|
|
120
|
-
});
|
|
334
|
+
export async function manageDriverSession(action, host, port, appIdentifier) {
|
|
335
|
+
switch (action) {
|
|
336
|
+
case 'status': {
|
|
337
|
+
return handleStatusAction();
|
|
121
338
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
app: null,
|
|
125
|
-
identifier: null,
|
|
126
|
-
host: null,
|
|
127
|
-
port: null,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (action === 'start') {
|
|
131
|
-
// Reset any existing connections to ensure fresh connection
|
|
132
|
-
if (appDiscovery) {
|
|
133
|
-
await appDiscovery.disconnectAll();
|
|
134
|
-
}
|
|
135
|
-
resetPluginClient();
|
|
136
|
-
const configuredHost = host ?? getDefaultHost();
|
|
137
|
-
const configuredPort = port ?? getDefaultPort();
|
|
138
|
-
// Strategy 1: Try localhost first (most reliable)
|
|
139
|
-
if (configuredHost !== 'localhost' && configuredHost !== '127.0.0.1') {
|
|
140
|
-
try {
|
|
141
|
-
const session = await tryConnect('localhost', configuredPort);
|
|
142
|
-
// Connect the singleton pluginClient so status checks work
|
|
143
|
-
await connectPlugin(session.host, session.port);
|
|
144
|
-
// Fetch app identifier after singleton is connected
|
|
145
|
-
const identifier = await fetchAppIdentifier();
|
|
146
|
-
currentSession = { ...session, identifier };
|
|
147
|
-
return `Session started with app: ${session.name} (localhost:${session.port})`;
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
// Localhost failed, will try configured host next
|
|
151
|
-
}
|
|
339
|
+
case 'start': {
|
|
340
|
+
return handleStartAction(host, port);
|
|
152
341
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const session = await tryConnect(configuredHost, configuredPort);
|
|
156
|
-
// Connect the singleton pluginClient so status checks work
|
|
157
|
-
await connectPlugin(session.host, session.port);
|
|
158
|
-
// Fetch app identifier after singleton is connected
|
|
159
|
-
const identifier = await fetchAppIdentifier();
|
|
160
|
-
currentSession = { ...session, identifier };
|
|
161
|
-
return `Session started with app: ${session.name} (${session.host}:${session.port})`;
|
|
342
|
+
case 'stop': {
|
|
343
|
+
return handleStopAction(appIdentifier);
|
|
162
344
|
}
|
|
163
|
-
|
|
164
|
-
|
|
345
|
+
default: {
|
|
346
|
+
return handleStopAction(appIdentifier);
|
|
165
347
|
}
|
|
166
|
-
// Strategy 3: Auto-discover on localhost (scan port range)
|
|
167
|
-
const localhostDiscovery = getAppDiscovery('localhost');
|
|
168
|
-
const firstApp = await localhostDiscovery.getFirstAvailableApp();
|
|
169
|
-
if (firstApp) {
|
|
170
|
-
try {
|
|
171
|
-
// Reset client again to connect to discovered port
|
|
172
|
-
resetPluginClient();
|
|
173
|
-
const session = await tryConnect('localhost', firstApp.port);
|
|
174
|
-
// Connect the singleton pluginClient so status checks work
|
|
175
|
-
await connectPlugin(session.host, session.port);
|
|
176
|
-
// Fetch app identifier after singleton is connected
|
|
177
|
-
const identifier = await fetchAppIdentifier();
|
|
178
|
-
currentSession = { ...session, identifier };
|
|
179
|
-
return `Session started with app: ${session.name} (localhost:${session.port})`;
|
|
180
|
-
}
|
|
181
|
-
catch {
|
|
182
|
-
// Discovery found app but connection failed
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// Strategy 4: Try default port on configured host as last resort
|
|
186
|
-
try {
|
|
187
|
-
resetPluginClient();
|
|
188
|
-
const session = await tryConnect(configuredHost, configuredPort);
|
|
189
|
-
// Connect the singleton pluginClient so status checks work
|
|
190
|
-
await connectPlugin(session.host, session.port);
|
|
191
|
-
// Fetch app identifier after singleton is connected
|
|
192
|
-
const identifier = await fetchAppIdentifier();
|
|
193
|
-
currentSession = { ...session, identifier };
|
|
194
|
-
return `Session started with app: ${session.name} (${session.host}:${session.port})`;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// All attempts failed
|
|
198
|
-
currentSession = null;
|
|
199
|
-
return `Session started (native IPC mode - no Tauri app found at localhost or ${configuredHost}:${configuredPort})`;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
// Stop action - disconnect all apps and reset initialization state
|
|
203
|
-
if (appDiscovery) {
|
|
204
|
-
await appDiscovery.disconnectAll();
|
|
205
348
|
}
|
|
206
|
-
resetPluginClient();
|
|
207
|
-
resetInitialization();
|
|
208
|
-
currentSession = null;
|
|
209
|
-
return 'Session stopped';
|
|
210
349
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
3
|
-
import { hasActiveSession,
|
|
2
|
+
import { connectPlugin } from './plugin-client.js';
|
|
3
|
+
import { hasActiveSession, getDefaultSession, resolveTargetApp } from './session-manager.js';
|
|
4
4
|
import { createMcpLogger } from '../logger.js';
|
|
5
5
|
import { buildScreenshotScript, buildScreenshotCaptureScript, getHtml2CanvasSource, HTML2CANVAS_SCRIPT_ID, } from './scripts/html2canvas-loader.js';
|
|
6
6
|
import { registerScript, isScriptRegistered } from './script-manager.js';
|
|
@@ -39,8 +39,8 @@ export async function ensureReady() {
|
|
|
39
39
|
if (!hasActiveSession()) {
|
|
40
40
|
throw new Error('No active session. Call tauri_driver_session with action "start" first to connect to a Tauri app.');
|
|
41
41
|
}
|
|
42
|
-
// Get session
|
|
43
|
-
const session =
|
|
42
|
+
// Get default session for initial connection
|
|
43
|
+
const session = getDefaultSession();
|
|
44
44
|
if (session) {
|
|
45
45
|
await connectPlugin(session.host, session.port);
|
|
46
46
|
}
|
|
@@ -57,10 +57,11 @@ export function resetInitialization() {
|
|
|
57
57
|
*
|
|
58
58
|
* @param script - JavaScript code to execute in the webview context
|
|
59
59
|
* @param windowId - Optional window label to target (defaults to "main")
|
|
60
|
+
* @param appIdentifier - Optional app identifier to target specific app
|
|
60
61
|
* @returns Result of the script execution with window context
|
|
61
62
|
*/
|
|
62
|
-
export async function executeInWebview(script, windowId) {
|
|
63
|
-
const { result } = await executeInWebviewWithContext(script, windowId);
|
|
63
|
+
export async function executeInWebview(script, windowId, appIdentifier) {
|
|
64
|
+
const { result } = await executeInWebviewWithContext(script, windowId, appIdentifier);
|
|
64
65
|
return result;
|
|
65
66
|
}
|
|
66
67
|
/**
|
|
@@ -68,18 +69,16 @@ export async function executeInWebview(script, windowId) {
|
|
|
68
69
|
*
|
|
69
70
|
* @param script - JavaScript code to execute in the webview context
|
|
70
71
|
* @param windowId - Optional window label to target (defaults to "main")
|
|
72
|
+
* @param appIdentifier - Optional app identifier to target specific app
|
|
71
73
|
* @returns Result of the script execution with window context
|
|
72
74
|
*/
|
|
73
|
-
export async function executeInWebviewWithContext(script, windowId) {
|
|
75
|
+
export async function executeInWebviewWithContext(script, windowId, appIdentifier) {
|
|
74
76
|
try {
|
|
75
77
|
// Ensure we're fully initialized
|
|
76
78
|
await ensureReady();
|
|
77
|
-
//
|
|
78
|
-
const session =
|
|
79
|
-
|
|
80
|
-
throw new Error('No active session');
|
|
81
|
-
}
|
|
82
|
-
const client = getPluginClient(session.host, session.port);
|
|
79
|
+
// Resolve target session
|
|
80
|
+
const session = resolveTargetApp(appIdentifier);
|
|
81
|
+
const client = session.client;
|
|
83
82
|
// Send script directly - Rust handles wrapping and IPC callbacks.
|
|
84
83
|
// Use 7s timeout (longer than Rust's 5s) so errors return before Node times out.
|
|
85
84
|
const response = await client.sendCommand({
|
|
@@ -185,9 +184,11 @@ export async function initializeConsoleCapture() {
|
|
|
185
184
|
*
|
|
186
185
|
* @param filter - Optional regex pattern to filter log messages
|
|
187
186
|
* @param since - Optional ISO timestamp to filter logs after this time
|
|
187
|
+
* @param windowId - Optional window label to target (defaults to "main")
|
|
188
|
+
* @param appIdentifier - Optional app identifier to target specific app
|
|
188
189
|
* @returns Formatted console logs as string
|
|
189
190
|
*/
|
|
190
|
-
export async function getConsoleLogs(filter, since) {
|
|
191
|
+
export async function getConsoleLogs(filter, since, windowId, appIdentifier) {
|
|
191
192
|
const filterStr = filter ? filter.replace(/'/g, '\\\'') : '';
|
|
192
193
|
const sinceStr = since || '';
|
|
193
194
|
const script = `
|
|
@@ -212,7 +213,7 @@ export async function getConsoleLogs(filter, since) {
|
|
|
212
213
|
'[ ' + new Date(l.timestamp).toISOString() + ' ] [ ' + l.level.toUpperCase() + ' ] ' + l.message
|
|
213
214
|
).join('\\n');
|
|
214
215
|
`;
|
|
215
|
-
return executeInWebview(script);
|
|
216
|
+
return executeInWebview(script, windowId, appIdentifier);
|
|
216
217
|
}
|
|
217
218
|
/**
|
|
218
219
|
* Clear all captured console logs.
|
|
@@ -284,11 +285,11 @@ async function prepareHtml2canvasScript(format, quality) {
|
|
|
284
285
|
/**
|
|
285
286
|
* Capture a screenshot of the entire webview.
|
|
286
287
|
*
|
|
287
|
-
* @param options - Screenshot options (format, quality, windowId)
|
|
288
|
+
* @param options - Screenshot options (format, quality, windowId, appIdentifier)
|
|
288
289
|
* @returns Screenshot result with image content
|
|
289
290
|
*/
|
|
290
291
|
export async function captureScreenshot(options = {}) {
|
|
291
|
-
const { format = 'png', quality = 90, windowId } = options;
|
|
292
|
+
const { format = 'png', quality = 90, windowId, appIdentifier } = options;
|
|
292
293
|
// Primary implementation: Use native platform-specific APIs
|
|
293
294
|
// - macOS: WKWebView takeSnapshot
|
|
294
295
|
// - Windows: WebView2 CapturePreview
|
|
@@ -296,7 +297,9 @@ export async function captureScreenshot(options = {}) {
|
|
|
296
297
|
try {
|
|
297
298
|
// Ensure we're fully initialized
|
|
298
299
|
await ensureReady();
|
|
299
|
-
|
|
300
|
+
// Resolve target session
|
|
301
|
+
const session = resolveTargetApp(appIdentifier);
|
|
302
|
+
const client = session.client;
|
|
300
303
|
// Use longer timeout (15s) for native screenshot - the Rust code waits up to 10s
|
|
301
304
|
const response = await client.sendCommand({
|
|
302
305
|
command: 'capture_native_screenshot',
|
|
@@ -7,11 +7,12 @@ import { SCRIPTS, buildScript, buildTypeScript, buildKeyEventScript } from './sc
|
|
|
7
7
|
// Base Schema for Window Targeting
|
|
8
8
|
// ============================================================================
|
|
9
9
|
/**
|
|
10
|
-
* Base schema mixin for tools that can target a specific window.
|
|
11
|
-
* All webview tools extend this to support multi-window
|
|
10
|
+
* Base schema mixin for tools that can target a specific window and app.
|
|
11
|
+
* All webview tools extend this to support multi-window and multi-app scenarios.
|
|
12
12
|
*/
|
|
13
13
|
export const WindowTargetSchema = z.object({
|
|
14
14
|
windowId: z.string().optional().describe('Window label to target (defaults to "main")'),
|
|
15
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
15
16
|
});
|
|
16
17
|
// ============================================================================
|
|
17
18
|
// Schemas
|
|
@@ -76,17 +77,17 @@ export const GetConsoleLogsSchema = WindowTargetSchema.extend({
|
|
|
76
77
|
// Implementation Functions
|
|
77
78
|
// ============================================================================
|
|
78
79
|
export async function interact(options) {
|
|
79
|
-
const { action, selector, x, y, duration, scrollX, scrollY, fromX, fromY, toX, toY, windowId } = options;
|
|
80
|
+
const { action, selector, x, y, duration, scrollX, scrollY, fromX, fromY, toX, toY, windowId, appIdentifier } = options;
|
|
80
81
|
// Handle swipe action separately since it has different logic
|
|
81
82
|
if (action === 'swipe') {
|
|
82
|
-
return performSwipe({ fromX, fromY, toX, toY, duration, windowId });
|
|
83
|
+
return performSwipe({ fromX, fromY, toX, toY, duration, windowId, appIdentifier });
|
|
83
84
|
}
|
|
84
85
|
// Handle focus action
|
|
85
86
|
if (action === 'focus') {
|
|
86
87
|
if (!selector) {
|
|
87
88
|
throw new Error('Focus action requires a selector');
|
|
88
89
|
}
|
|
89
|
-
return focusElement({ selector, windowId });
|
|
90
|
+
return focusElement({ selector, windowId, appIdentifier });
|
|
90
91
|
}
|
|
91
92
|
const script = buildScript(SCRIPTS.interact, {
|
|
92
93
|
action,
|
|
@@ -98,7 +99,7 @@ export async function interact(options) {
|
|
|
98
99
|
scrollY: scrollY ?? 0,
|
|
99
100
|
});
|
|
100
101
|
try {
|
|
101
|
-
return await executeInWebview(script, windowId);
|
|
102
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
102
103
|
}
|
|
103
104
|
catch (error) {
|
|
104
105
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -106,13 +107,13 @@ export async function interact(options) {
|
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
async function performSwipe(options) {
|
|
109
|
-
const { fromX, fromY, toX, toY, duration = 300, windowId } = options;
|
|
110
|
+
const { fromX, fromY, toX, toY, duration = 300, windowId, appIdentifier } = options;
|
|
110
111
|
if (fromX === undefined || fromY === undefined || toX === undefined || toY === undefined) {
|
|
111
112
|
throw new Error('Swipe action requires fromX, fromY, toX, and toY coordinates');
|
|
112
113
|
}
|
|
113
114
|
const script = buildScript(SCRIPTS.swipe, { fromX, fromY, toX, toY, duration });
|
|
114
115
|
try {
|
|
115
|
-
return await executeInWebview(script, windowId);
|
|
116
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
116
117
|
}
|
|
117
118
|
catch (error) {
|
|
118
119
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -120,9 +121,9 @@ async function performSwipe(options) {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
export async function screenshot(options = {}) {
|
|
123
|
-
const { quality, format = 'png', windowId, filePath } = options;
|
|
124
|
+
const { quality, format = 'png', windowId, filePath, appIdentifier } = options;
|
|
124
125
|
// Use the native screenshot function from webview-executor
|
|
125
|
-
const result = await captureScreenshot({ format, quality, windowId });
|
|
126
|
+
const result = await captureScreenshot({ format, quality, windowId, appIdentifier });
|
|
126
127
|
// If filePath is provided, write to file instead of returning base64
|
|
127
128
|
if (filePath) {
|
|
128
129
|
// Find the image content in the result
|
|
@@ -139,7 +140,7 @@ export async function screenshot(options = {}) {
|
|
|
139
140
|
return result;
|
|
140
141
|
}
|
|
141
142
|
export async function keyboard(options) {
|
|
142
|
-
const { action, selectorOrKey, textOrModifiers, modifiers, windowId } = options;
|
|
143
|
+
const { action, selectorOrKey, textOrModifiers, modifiers, windowId, appIdentifier } = options;
|
|
143
144
|
// Handle the different parameter combinations based on action
|
|
144
145
|
if (action === 'type') {
|
|
145
146
|
const selector = selectorOrKey;
|
|
@@ -149,7 +150,7 @@ export async function keyboard(options) {
|
|
|
149
150
|
}
|
|
150
151
|
const script = buildTypeScript(selector, text);
|
|
151
152
|
try {
|
|
152
|
-
return await executeInWebview(script, windowId);
|
|
153
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
153
154
|
}
|
|
154
155
|
catch (error) {
|
|
155
156
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -164,7 +165,7 @@ export async function keyboard(options) {
|
|
|
164
165
|
}
|
|
165
166
|
const script = buildKeyEventScript(action, key, mods || []);
|
|
166
167
|
try {
|
|
167
|
-
return await executeInWebview(script, windowId);
|
|
168
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
168
169
|
}
|
|
169
170
|
catch (error) {
|
|
170
171
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -172,10 +173,10 @@ export async function keyboard(options) {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
export async function waitFor(options) {
|
|
175
|
-
const { type, value, timeout = 5000, windowId } = options;
|
|
176
|
+
const { type, value, timeout = 5000, windowId, appIdentifier } = options;
|
|
176
177
|
const script = buildScript(SCRIPTS.waitFor, { type, value, timeout });
|
|
177
178
|
try {
|
|
178
|
-
return await executeInWebview(script, windowId);
|
|
179
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
179
180
|
}
|
|
180
181
|
catch (error) {
|
|
181
182
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -183,14 +184,14 @@ export async function waitFor(options) {
|
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
export async function getStyles(options) {
|
|
186
|
-
const { selector, properties, multiple = false, windowId } = options;
|
|
187
|
+
const { selector, properties, multiple = false, windowId, appIdentifier } = options;
|
|
187
188
|
const script = buildScript(SCRIPTS.getStyles, {
|
|
188
189
|
selector,
|
|
189
190
|
properties: properties || [],
|
|
190
191
|
multiple,
|
|
191
192
|
});
|
|
192
193
|
try {
|
|
193
|
-
return await executeInWebview(script, windowId);
|
|
194
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
194
195
|
}
|
|
195
196
|
catch (error) {
|
|
196
197
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -198,7 +199,7 @@ export async function getStyles(options) {
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
export async function executeJavaScript(options) {
|
|
201
|
-
const { script, args, windowId } = options;
|
|
202
|
+
const { script, args, windowId, appIdentifier } = options;
|
|
202
203
|
// If args are provided, we need to inject them into the script context
|
|
203
204
|
const wrappedScript = args && args.length > 0
|
|
204
205
|
? `
|
|
@@ -209,7 +210,7 @@ export async function executeJavaScript(options) {
|
|
|
209
210
|
`
|
|
210
211
|
: script;
|
|
211
212
|
try {
|
|
212
|
-
const { result, windowLabel, warning } = await executeInWebviewWithContext(wrappedScript, windowId);
|
|
213
|
+
const { result, windowLabel, warning } = await executeInWebviewWithContext(wrappedScript, windowId, appIdentifier);
|
|
213
214
|
// Build response with window context
|
|
214
215
|
let response = result;
|
|
215
216
|
if (warning) {
|
|
@@ -225,10 +226,10 @@ export async function executeJavaScript(options) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
export async function focusElement(options) {
|
|
228
|
-
const { selector, windowId } = options;
|
|
229
|
+
const { selector, windowId, appIdentifier } = options;
|
|
229
230
|
const script = buildScript(SCRIPTS.focus, { selector });
|
|
230
231
|
try {
|
|
231
|
-
return await executeInWebview(script, windowId);
|
|
232
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
232
233
|
}
|
|
233
234
|
catch (error) {
|
|
234
235
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -239,10 +240,10 @@ export async function focusElement(options) {
|
|
|
239
240
|
* Find an element using various selector strategies.
|
|
240
241
|
*/
|
|
241
242
|
export async function findElement(options) {
|
|
242
|
-
const { selector, strategy, windowId } = options;
|
|
243
|
+
const { selector, strategy, windowId, appIdentifier } = options;
|
|
243
244
|
const script = buildScript(SCRIPTS.findElement, { selector, strategy });
|
|
244
245
|
try {
|
|
245
|
-
return await executeInWebview(script, windowId);
|
|
246
|
+
return await executeInWebview(script, windowId, appIdentifier);
|
|
246
247
|
}
|
|
247
248
|
catch (error) {
|
|
248
249
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -253,9 +254,9 @@ export async function findElement(options) {
|
|
|
253
254
|
* Get console logs from the webview.
|
|
254
255
|
*/
|
|
255
256
|
export async function getConsoleLogs(options = {}) {
|
|
256
|
-
const { filter, since } = options;
|
|
257
|
+
const { filter, since, windowId, appIdentifier } = options;
|
|
257
258
|
try {
|
|
258
|
-
return await getConsoleLogsFromCapture(filter, since);
|
|
259
|
+
return await getConsoleLogsFromCapture(filter, since, windowId, appIdentifier);
|
|
259
260
|
}
|
|
260
261
|
catch (error) {
|
|
261
262
|
const message = error instanceof Error ? error.message : String(error);
|
package/dist/monitor/logs.js
CHANGED
|
@@ -8,14 +8,15 @@ export const ReadLogsSchema = z.object({
|
|
|
8
8
|
filter: z.string().optional().describe('Regex or keyword to filter logs'),
|
|
9
9
|
since: z.string().optional().describe('ISO timestamp to filter logs since (e.g. 2023-10-27T10:00:00Z)'),
|
|
10
10
|
windowId: z.string().optional().describe('Window label for console logs (defaults to "main")'),
|
|
11
|
+
appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID for console logs. Defaults to the only connected app or the default app if multiple are connected.'),
|
|
11
12
|
});
|
|
12
13
|
export async function readLogs(options) {
|
|
13
|
-
const { source, lines = 50, filter, since, windowId } = options;
|
|
14
|
+
const { source, lines = 50, filter, since, windowId, appIdentifier } = options;
|
|
14
15
|
try {
|
|
15
16
|
let output = '';
|
|
16
17
|
// Handle console logs (webview JS logs)
|
|
17
18
|
if (source === 'console') {
|
|
18
|
-
return await getConsoleLogs({ filter, since, windowId });
|
|
19
|
+
return await getConsoleLogs({ filter, since, windowId, appIdentifier });
|
|
19
20
|
}
|
|
20
21
|
if (source === 'android') {
|
|
21
22
|
// Find adb - check ANDROID_HOME first, then fall back to PATH
|
package/dist/tools-registry.js
CHANGED
|
@@ -8,6 +8,11 @@ import { manageDriverSession, ManageDriverSessionSchema, } from './driver/sessio
|
|
|
8
8
|
import { readLogs, ReadLogsSchema } from './monitor/logs.js';
|
|
9
9
|
import { executeIPCCommand, manageIPCMonitoring, getIPCEvents, emitTestEvent, getBackendState, manageWindow, ExecuteIPCCommandSchema, ManageIPCMonitoringSchema, GetIPCEventsSchema, EmitTestEventSchema, GetBackendStateSchema, ManageWindowSchema, } from './driver/plugin-commands.js';
|
|
10
10
|
import { interact, screenshot, keyboard, waitFor, getStyles, executeJavaScript, findElement, InteractSchema, ScreenshotSchema, KeyboardSchema, WaitForSchema, GetStylesSchema, ExecuteJavaScriptSchema, FindElementSchema, } from './driver/webview-interactions.js';
|
|
11
|
+
/**
|
|
12
|
+
* Standard multi-app description for webview tools.
|
|
13
|
+
*/
|
|
14
|
+
const MULTI_APP_DESC = 'Targets the only connected app, or the default app if multiple are connected. ' +
|
|
15
|
+
'Specify appIdentifier (port or bundle ID) to target a specific app.';
|
|
11
16
|
/**
|
|
12
17
|
* Tool categories for organization
|
|
13
18
|
*/
|
|
@@ -136,11 +141,12 @@ export const TOOLS = [
|
|
|
136
141
|
{
|
|
137
142
|
name: 'tauri_driver_session',
|
|
138
143
|
description: '[Tauri Apps Only] Start/stop automation session to connect to a RUNNING Tauri app. ' +
|
|
139
|
-
'
|
|
140
|
-
'The
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
+
'Supports multiple concurrent app connections - each app runs on a unique port. ' +
|
|
145
|
+
'The most recently connected app becomes the "default" app used when no appIdentifier is specified. ' +
|
|
146
|
+
'Use action "status" to check connection state: returns single app format when 1 app connected, ' +
|
|
147
|
+
'or array format with "isDefault" indicator when multiple apps connected. ' +
|
|
148
|
+
'Action "stop" without appIdentifier stops ALL sessions; with appIdentifier stops only that app. ' +
|
|
149
|
+
'The identifier field (e.g., "com.example.myapp") uniquely identifies each app. ' +
|
|
144
150
|
'REQUIRED before using other tauri_webview_* or tauri_plugin_* tools. ' +
|
|
145
151
|
'Connects via WebSocket to the MCP Bridge plugin in the Tauri app. ' +
|
|
146
152
|
'For browser automation, use Chrome DevTools MCP instead. ' +
|
|
@@ -156,13 +162,14 @@ export const TOOLS = [
|
|
|
156
162
|
},
|
|
157
163
|
handler: async (args) => {
|
|
158
164
|
const parsed = ManageDriverSessionSchema.parse(args);
|
|
159
|
-
return await manageDriverSession(parsed.action, parsed.host, parsed.port);
|
|
165
|
+
return await manageDriverSession(parsed.action, parsed.host, parsed.port, parsed.appIdentifier);
|
|
160
166
|
},
|
|
161
167
|
},
|
|
162
168
|
{
|
|
163
169
|
name: 'tauri_webview_find_element',
|
|
164
170
|
description: '[Tauri Apps Only] Find DOM elements in a running Tauri app\'s webview. ' +
|
|
165
171
|
'Requires active tauri_driver_session. ' +
|
|
172
|
+
MULTI_APP_DESC + ' ' +
|
|
166
173
|
'For browser pages or documentation sites, use Chrome DevTools MCP instead.',
|
|
167
174
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
168
175
|
schema: FindElementSchema,
|
|
@@ -177,6 +184,7 @@ export const TOOLS = [
|
|
|
177
184
|
selector: parsed.selector,
|
|
178
185
|
strategy: parsed.strategy,
|
|
179
186
|
windowId: parsed.windowId,
|
|
187
|
+
appIdentifier: parsed.appIdentifier,
|
|
180
188
|
});
|
|
181
189
|
},
|
|
182
190
|
},
|
|
@@ -222,6 +230,7 @@ export const TOOLS = [
|
|
|
222
230
|
name: 'tauri_webview_screenshot',
|
|
223
231
|
description: '[Tauri Apps Only] Screenshot a running Tauri app\'s webview. ' +
|
|
224
232
|
'Requires active tauri_driver_session. Captures only visible viewport. ' +
|
|
233
|
+
MULTI_APP_DESC + ' ' +
|
|
225
234
|
'For browser screenshots, use Chrome DevTools MCP instead. ' +
|
|
226
235
|
'For Electron apps, this will NOT work.',
|
|
227
236
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
@@ -238,6 +247,7 @@ export const TOOLS = [
|
|
|
238
247
|
format: parsed.format,
|
|
239
248
|
windowId: parsed.windowId,
|
|
240
249
|
filePath: parsed.filePath,
|
|
250
|
+
appIdentifier: parsed.appIdentifier,
|
|
241
251
|
});
|
|
242
252
|
// If saved to file, return text confirmation
|
|
243
253
|
if ('filePath' in result) {
|
|
@@ -251,6 +261,7 @@ export const TOOLS = [
|
|
|
251
261
|
name: 'tauri_webview_keyboard',
|
|
252
262
|
description: '[Tauri Apps Only] Type text or send keyboard events in a Tauri app. ' +
|
|
253
263
|
'Requires active tauri_driver_session. ' +
|
|
264
|
+
MULTI_APP_DESC + ' ' +
|
|
254
265
|
'For browser keyboard input, use Chrome DevTools MCP instead.',
|
|
255
266
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
256
267
|
schema: KeyboardSchema,
|
|
@@ -268,6 +279,7 @@ export const TOOLS = [
|
|
|
268
279
|
selectorOrKey: parsed.selector,
|
|
269
280
|
textOrModifiers: parsed.text,
|
|
270
281
|
windowId: parsed.windowId,
|
|
282
|
+
appIdentifier: parsed.appIdentifier,
|
|
271
283
|
});
|
|
272
284
|
}
|
|
273
285
|
return await keyboard({
|
|
@@ -275,6 +287,7 @@ export const TOOLS = [
|
|
|
275
287
|
selectorOrKey: parsed.key,
|
|
276
288
|
textOrModifiers: parsed.modifiers,
|
|
277
289
|
windowId: parsed.windowId,
|
|
290
|
+
appIdentifier: parsed.appIdentifier,
|
|
278
291
|
});
|
|
279
292
|
},
|
|
280
293
|
},
|
|
@@ -282,6 +295,7 @@ export const TOOLS = [
|
|
|
282
295
|
name: 'tauri_webview_wait_for',
|
|
283
296
|
description: '[Tauri Apps Only] Wait for elements, text, or IPC events in a Tauri app. ' +
|
|
284
297
|
'Requires active tauri_driver_session. ' +
|
|
298
|
+
MULTI_APP_DESC + ' ' +
|
|
285
299
|
'For browser waits, use Chrome DevTools MCP instead.',
|
|
286
300
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
287
301
|
schema: WaitForSchema,
|
|
@@ -297,6 +311,7 @@ export const TOOLS = [
|
|
|
297
311
|
value: parsed.value,
|
|
298
312
|
timeout: parsed.timeout,
|
|
299
313
|
windowId: parsed.windowId,
|
|
314
|
+
appIdentifier: parsed.appIdentifier,
|
|
300
315
|
});
|
|
301
316
|
},
|
|
302
317
|
},
|
|
@@ -304,6 +319,7 @@ export const TOOLS = [
|
|
|
304
319
|
name: 'tauri_webview_get_styles',
|
|
305
320
|
description: '[Tauri Apps Only] Get computed CSS styles from elements in a Tauri app. ' +
|
|
306
321
|
'Requires active tauri_driver_session. ' +
|
|
322
|
+
MULTI_APP_DESC + ' ' +
|
|
307
323
|
'For browser style inspection, use Chrome DevTools MCP instead.',
|
|
308
324
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
309
325
|
schema: GetStylesSchema,
|
|
@@ -319,6 +335,7 @@ export const TOOLS = [
|
|
|
319
335
|
properties: parsed.properties,
|
|
320
336
|
multiple: parsed.multiple,
|
|
321
337
|
windowId: parsed.windowId,
|
|
338
|
+
appIdentifier: parsed.appIdentifier,
|
|
322
339
|
});
|
|
323
340
|
},
|
|
324
341
|
},
|
|
@@ -328,6 +345,7 @@ export const TOOLS = [
|
|
|
328
345
|
'Requires active tauri_driver_session. Has access to window.__TAURI__. ' +
|
|
329
346
|
'If you need a return value, it must be JSON-serializable. ' +
|
|
330
347
|
'For functions that return values, use an IIFE: "(() => { return 5; })()" not "() => { return 5; }". ' +
|
|
348
|
+
MULTI_APP_DESC + ' ' +
|
|
331
349
|
'For browser JS execution, use Chrome DevTools MCP instead.',
|
|
332
350
|
category: TOOL_CATEGORIES.UI_AUTOMATION,
|
|
333
351
|
schema: ExecuteJavaScriptSchema,
|
|
@@ -343,6 +361,7 @@ export const TOOLS = [
|
|
|
343
361
|
script: parsed.script,
|
|
344
362
|
args: parsed.args,
|
|
345
363
|
windowId: parsed.windowId,
|
|
364
|
+
appIdentifier: parsed.appIdentifier,
|
|
346
365
|
});
|
|
347
366
|
},
|
|
348
367
|
},
|
|
@@ -362,7 +381,11 @@ export const TOOLS = [
|
|
|
362
381
|
},
|
|
363
382
|
handler: async (args) => {
|
|
364
383
|
const parsed = ExecuteIPCCommandSchema.parse(args);
|
|
365
|
-
return await executeIPCCommand(
|
|
384
|
+
return await executeIPCCommand({
|
|
385
|
+
command: parsed.command,
|
|
386
|
+
args: parsed.args,
|
|
387
|
+
appIdentifier: parsed.appIdentifier,
|
|
388
|
+
});
|
|
366
389
|
},
|
|
367
390
|
},
|
|
368
391
|
{
|
|
@@ -381,7 +404,7 @@ export const TOOLS = [
|
|
|
381
404
|
},
|
|
382
405
|
handler: async (args) => {
|
|
383
406
|
const parsed = ManageIPCMonitoringSchema.parse(args);
|
|
384
|
-
return await manageIPCMonitoring(parsed.action);
|
|
407
|
+
return await manageIPCMonitoring(parsed.action, parsed.appIdentifier);
|
|
385
408
|
},
|
|
386
409
|
},
|
|
387
410
|
{
|
|
@@ -398,7 +421,7 @@ export const TOOLS = [
|
|
|
398
421
|
},
|
|
399
422
|
handler: async (args) => {
|
|
400
423
|
const parsed = GetIPCEventsSchema.parse(args);
|
|
401
|
-
return await getIPCEvents(parsed.filter);
|
|
424
|
+
return await getIPCEvents(parsed.filter, parsed.appIdentifier);
|
|
402
425
|
},
|
|
403
426
|
},
|
|
404
427
|
{
|
|
@@ -416,7 +439,7 @@ export const TOOLS = [
|
|
|
416
439
|
},
|
|
417
440
|
handler: async (args) => {
|
|
418
441
|
const parsed = EmitTestEventSchema.parse(args);
|
|
419
|
-
return await emitTestEvent(parsed.eventName, parsed.payload);
|
|
442
|
+
return await emitTestEvent(parsed.eventName, parsed.payload, parsed.appIdentifier);
|
|
420
443
|
},
|
|
421
444
|
},
|
|
422
445
|
{
|
|
@@ -431,8 +454,9 @@ export const TOOLS = [
|
|
|
431
454
|
readOnlyHint: true,
|
|
432
455
|
openWorldHint: false,
|
|
433
456
|
},
|
|
434
|
-
handler: async () => {
|
|
435
|
-
|
|
457
|
+
handler: async (args) => {
|
|
458
|
+
const parsed = GetBackendStateSchema.parse(args);
|
|
459
|
+
return await getBackendState({ appIdentifier: parsed.appIdentifier });
|
|
436
460
|
},
|
|
437
461
|
},
|
|
438
462
|
// Window Management Tools
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypothesi/tauri-mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A Model Context Protocol server for Tauri v2
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "A Model Context Protocol server for use with Tauri v2 applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mcp-server-tauri": "./dist/index.js"
|