@hypothesi/tauri-mcp-server 0.5.0 → 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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@hypothesi/tauri-mcp-server)](https://www.npmjs.com/package/@hypothesi/tauri-mcp-server)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-8b5cf6.svg)](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.
@@ -174,14 +174,33 @@ export class PluginClient extends EventEmitter {
174
174
  }
175
175
  // Singleton instance
176
176
  let pluginClient = null;
177
+ /**
178
+ * Gets the existing singleton PluginClient without creating or modifying it.
179
+ * Use this for status checks where you don't want to affect the current connection.
180
+ *
181
+ * @returns The existing PluginClient or null if none exists
182
+ */
183
+ export function getExistingPluginClient() {
184
+ return pluginClient;
185
+ }
177
186
  /**
178
187
  * Gets or creates a singleton PluginClient.
188
+ *
189
+ * If host/port are provided and differ from the existing client's configuration,
190
+ * the existing client is disconnected and a new one is created. This ensures
191
+ * that session start with a specific port always uses that port.
192
+ *
179
193
  * @param host Optional host override
180
194
  * @param port Optional port override
181
195
  */
182
196
  export function getPluginClient(host, port) {
183
197
  const resolvedHost = host ?? getDefaultHost();
184
198
  const resolvedPort = port ?? getDefaultPort();
199
+ // If singleton exists but host/port don't match, reset it
200
+ if (pluginClient && (pluginClient.host !== resolvedHost || pluginClient.port !== resolvedPort)) {
201
+ pluginClient.disconnect();
202
+ pluginClient = null;
203
+ }
185
204
  if (!pluginClient) {
186
205
  pluginClient = new PluginClient(resolvedHost, resolvedPort);
187
206
  }
@@ -202,6 +221,23 @@ export async function connectPlugin(host, port) {
202
221
  await client.connect();
203
222
  }
204
223
  }
224
+ /**
225
+ * Ensures a session is active and connects to the plugin using session config.
226
+ * This should be used by all tools that require a connected Tauri app.
227
+ *
228
+ * @param appIdentifier - Optional app identifier to target specific app
229
+ * @throws Error if no session is active
230
+ */
231
+ export async function ensureSessionAndConnect(appIdentifier) {
232
+ // Import dynamically to avoid circular dependency
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();
238
+ }
239
+ return session.client;
240
+ }
205
241
  export async function disconnectPlugin() {
206
242
  const client = getPluginClient();
207
243
  client.disconnect();
@@ -1,14 +1,15 @@
1
1
  import { z } from 'zod';
2
- import { getPluginClient, connectPlugin } from './plugin-client.js';
2
+ import { ensureSessionAndConnect, getExistingPluginClient } from './plugin-client.js';
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(command, args = {}) {
8
+ export async function executeIPCCommand(options) {
8
9
  try {
9
- // Ensure we're connected to the plugin
10
- await connectPlugin();
11
- const client = getPluginClient();
10
+ const { command, args = {}, appIdentifier } = options;
11
+ // Ensure we have an active session and are connected
12
+ const client = await ensureSessionAndConnect(appIdentifier);
12
13
  // Send IPC command via WebSocket to the mcp-bridge plugin
13
14
  const response = await client.sendCommand({
14
15
  command: 'invoke_tauri',
@@ -27,19 +28,20 @@ export async function executeIPCCommand(command, args = {}) {
27
28
  // Combined schema for managing IPC monitoring
28
29
  export const ManageIPCMonitoringSchema = z.object({
29
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.'),
30
32
  });
31
33
  // Keep individual schemas for backward compatibility if needed
32
34
  export const StartIPCMonitoringSchema = z.object({});
33
35
  export const StopIPCMonitoringSchema = z.object({});
34
- export async function manageIPCMonitoring(action) {
36
+ export async function manageIPCMonitoring(action, appIdentifier) {
35
37
  if (action === 'start') {
36
- return startIPCMonitoring();
38
+ return startIPCMonitoring(appIdentifier);
37
39
  }
38
- return stopIPCMonitoring();
40
+ return stopIPCMonitoring(appIdentifier);
39
41
  }
40
- export async function startIPCMonitoring() {
42
+ export async function startIPCMonitoring(appIdentifier) {
41
43
  try {
42
- const result = await executeIPCCommand('plugin:mcp-bridge|start_ipc_monitor');
44
+ const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|start_ipc_monitor', appIdentifier });
43
45
  const parsed = JSON.parse(result);
44
46
  if (!parsed.success) {
45
47
  throw new Error(parsed.error || 'Unknown error');
@@ -51,9 +53,9 @@ export async function startIPCMonitoring() {
51
53
  throw new Error(`Failed to start IPC monitoring: ${message}`);
52
54
  }
53
55
  }
54
- export async function stopIPCMonitoring() {
56
+ export async function stopIPCMonitoring(appIdentifier) {
55
57
  try {
56
- const result = await executeIPCCommand('plugin:mcp-bridge|stop_ipc_monitor');
58
+ const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|stop_ipc_monitor', appIdentifier });
57
59
  const parsed = JSON.parse(result);
58
60
  if (!parsed.success) {
59
61
  throw new Error(parsed.error || 'Unknown error');
@@ -67,10 +69,11 @@ export async function stopIPCMonitoring() {
67
69
  }
68
70
  export const GetIPCEventsSchema = z.object({
69
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.'),
70
73
  });
71
- export async function getIPCEvents(filter) {
74
+ export async function getIPCEvents(filter, appIdentifier) {
72
75
  try {
73
- const result = await executeIPCCommand('plugin:mcp-bridge|get_ipc_events');
76
+ const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_ipc_events', appIdentifier });
74
77
  const parsed = JSON.parse(result);
75
78
  if (!parsed.success) {
76
79
  throw new Error(parsed.error || 'Unknown error');
@@ -92,12 +95,17 @@ export async function getIPCEvents(filter) {
92
95
  export const EmitTestEventSchema = z.object({
93
96
  eventName: z.string(),
94
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.'),
95
99
  });
96
- export async function emitTestEvent(eventName, payload) {
100
+ export async function emitTestEvent(eventName, payload, appIdentifier) {
97
101
  try {
98
- const result = await executeIPCCommand('plugin:mcp-bridge|emit_event', {
99
- eventName,
100
- payload,
102
+ const result = await executeIPCCommand({
103
+ command: 'plugin:mcp-bridge|emit_event',
104
+ args: {
105
+ eventName,
106
+ payload,
107
+ },
108
+ appIdentifier,
101
109
  });
102
110
  const parsed = JSON.parse(result);
103
111
  if (!parsed.success) {
@@ -110,10 +118,12 @@ export async function emitTestEvent(eventName, payload) {
110
118
  throw new Error(`Failed to emit event: ${message}`);
111
119
  }
112
120
  }
113
- export const GetWindowInfoSchema = z.object({});
114
- export async function getWindowInfo() {
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) {
115
125
  try {
116
- const result = await executeIPCCommand('plugin:mcp-bridge|get_window_info');
126
+ const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_window_info', appIdentifier });
117
127
  const parsed = JSON.parse(result);
118
128
  if (!parsed.success) {
119
129
  throw new Error(parsed.error || 'Unknown error');
@@ -125,10 +135,39 @@ export async function getWindowInfo() {
125
135
  throw new Error(`Failed to get window info: ${message}`);
126
136
  }
127
137
  }
128
- export const GetBackendStateSchema = z.object({});
129
- export async function getBackendState() {
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
+ });
141
+ /**
142
+ * Get backend state from the connected Tauri app.
143
+ *
144
+ * This function can work in two modes:
145
+ * 1. Normal mode: Requires an active session (for MCP tool calls)
146
+ * 2. Setup mode: Uses existing connected client (for session setup)
147
+ *
148
+ * @param useExistingClient If true, uses the existing connected client without
149
+ * session validation. Used during session setup before currentSession is set.
150
+ */
151
+ export async function getBackendState(options = {}) {
130
152
  try {
131
- const result = await executeIPCCommand('plugin:mcp-bridge|get_backend_state');
153
+ const { useExistingClient = false, appIdentifier } = options;
154
+ if (useExistingClient) {
155
+ // During session setup, use the already-connected client directly
156
+ const client = getExistingPluginClient();
157
+ if (!client || !client.isConnected()) {
158
+ throw new Error('No connected client available');
159
+ }
160
+ const response = await client.sendCommand({
161
+ command: 'invoke_tauri',
162
+ args: { command: 'plugin:mcp-bridge|get_backend_state', args: {} },
163
+ });
164
+ if (!response.success) {
165
+ throw new Error(response.error || 'Unknown error');
166
+ }
167
+ return JSON.stringify(response.data);
168
+ }
169
+ // Normal mode: use executeIPCCommand which validates session
170
+ const result = await executeIPCCommand({ command: 'plugin:mcp-bridge|get_backend_state', appIdentifier });
132
171
  const parsed = JSON.parse(result);
133
172
  if (!parsed.success) {
134
173
  throw new Error(parsed.error || 'Unknown error');
@@ -147,10 +186,9 @@ export const ListWindowsSchema = z.object({});
147
186
  /**
148
187
  * Lists all open webview windows in the Tauri application.
149
188
  */
150
- export async function listWindows() {
189
+ export async function listWindows(appIdentifier) {
151
190
  try {
152
- await connectPlugin();
153
- const client = getPluginClient();
191
+ const client = await ensureSessionAndConnect(appIdentifier);
154
192
  const response = await client.sendCommand({
155
193
  command: 'list_windows',
156
194
  });
@@ -184,8 +222,7 @@ export const ResizeWindowSchema = z.object({
184
222
  */
185
223
  export async function resizeWindow(options) {
186
224
  try {
187
- await connectPlugin();
188
- const client = getPluginClient();
225
+ const client = await ensureSessionAndConnect(options.appIdentifier);
189
226
  const response = await client.sendCommand({
190
227
  command: 'resize_window',
191
228
  args: {
@@ -216,6 +253,7 @@ export const ManageWindowSchema = z.object({
216
253
  .describe('Height in pixels (required for "resize" action)'),
217
254
  logical: z.boolean().optional().default(true)
218
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.'),
219
257
  });
220
258
  /**
221
259
  * Unified window management function.
@@ -229,15 +267,14 @@ export const ManageWindowSchema = z.object({
229
267
  * @returns JSON string with the result
230
268
  */
231
269
  export async function manageWindow(options) {
232
- const { action, windowId, width, height, logical } = options;
270
+ const { action, windowId, width, height, logical, appIdentifier } = options;
233
271
  switch (action) {
234
272
  case 'list': {
235
- return listWindows();
273
+ return listWindows(appIdentifier);
236
274
  }
237
275
  case 'info': {
238
276
  try {
239
- await connectPlugin();
240
- const client = getPluginClient();
277
+ const client = await ensureSessionAndConnect(appIdentifier);
241
278
  const response = await client.sendCommand({
242
279
  command: 'get_window_info',
243
280
  args: { windowId },
@@ -256,7 +293,7 @@ export async function manageWindow(options) {
256
293
  if (width === undefined || height === undefined) {
257
294
  throw new Error('width and height are required for resize action');
258
295
  }
259
- return resizeWindow({ width, height, windowId, logical });
296
+ return resizeWindow({ width, height, windowId, logical, appIdentifier });
260
297
  }
261
298
  default: {
262
299
  throw new Error(`Unknown action: ${action}`);
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @internal This module is for internal use only and is not exposed as MCP tools.
8
8
  */
9
- import { getPluginClient, connectPlugin } from './plugin-client.js';
9
+ import { ensureSessionAndConnect } from './plugin-client.js';
10
10
  /**
11
11
  * Registers a script to be injected into the webview.
12
12
  *
@@ -20,8 +20,7 @@ import { getPluginClient, connectPlugin } from './plugin-client.js';
20
20
  * @returns Promise resolving to registration result
21
21
  */
22
22
  export async function registerScript(id, type, content, windowLabel) {
23
- await connectPlugin();
24
- const client = getPluginClient();
23
+ const client = await ensureSessionAndConnect();
25
24
  const response = await client.sendCommand({
26
25
  command: 'register_script',
27
26
  args: { id, type, content, windowLabel },
@@ -39,8 +38,7 @@ export async function registerScript(id, type, content, windowLabel) {
39
38
  * @returns Promise resolving to removal result
40
39
  */
41
40
  export async function removeScript(id, windowLabel) {
42
- await connectPlugin();
43
- const client = getPluginClient();
41
+ const client = await ensureSessionAndConnect();
44
42
  const response = await client.sendCommand({
45
43
  command: 'remove_script',
46
44
  args: { id, windowLabel },
@@ -57,8 +55,7 @@ export async function removeScript(id, windowLabel) {
57
55
  * @returns Promise resolving to the number of scripts cleared
58
56
  */
59
57
  export async function clearScripts(windowLabel) {
60
- await connectPlugin();
61
- const client = getPluginClient();
58
+ const client = await ensureSessionAndConnect();
62
59
  const response = await client.sendCommand({
63
60
  command: 'clear_scripts',
64
61
  args: { windowLabel },
@@ -74,8 +71,7 @@ export async function clearScripts(windowLabel) {
74
71
  * @returns Promise resolving to the list of registered scripts
75
72
  */
76
73
  export async function getScripts() {
77
- await connectPlugin();
78
- const client = getPluginClient();
74
+ const client = await ensureSessionAndConnect();
79
75
  const response = await client.sendCommand({
80
76
  command: 'get_scripts',
81
77
  args: {},
@@ -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 { resetPluginClient, getPluginClient, connectPlugin } from './plugin-client.js';
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,19 +23,254 @@ 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
- // Track current session info including app identifier for session reuse
31
- let appDiscovery = null, currentSession = null;
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;
37
+ /**
38
+ * Check if any session is currently active.
39
+ * @returns true if at least one session exists
40
+ */
41
+ export function hasActiveSession() {
42
+ return activeSessions.size > 0;
43
+ }
44
+ /**
45
+ * Get a specific session by port.
46
+ */
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());
65
+ }
32
66
  function getAppDiscovery(host) {
33
67
  if (!appDiscovery || appDiscovery.host !== host) {
34
68
  appDiscovery = new AppDiscovery(host);
35
69
  }
36
70
  return appDiscovery;
37
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
+ }
38
274
  // ============================================================================
39
275
  // Session Management
40
276
  // ============================================================================
@@ -53,17 +289,24 @@ async function tryConnect(host, port) {
53
289
  }
54
290
  /**
55
291
  * Fetch the app identifier from the backend state.
56
- * Must be called after the singleton pluginClient is connected.
292
+ * Must be called after a PluginClient is connected.
57
293
  *
294
+ * @param client - The PluginClient to query
58
295
  * @returns The app identifier (bundle ID) or null if not available. Returns null when:
59
296
  * - The plugin doesn't support the identifier field (older versions)
60
297
  * - The backend state request fails
61
298
  * - The identifier field is missing from the response
62
299
  */
63
- async function fetchAppIdentifier() {
300
+ async function fetchAppIdentifier(client) {
64
301
  try {
65
- const stateJson = await getBackendState();
66
- const state = JSON.parse(stateJson);
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;
67
310
  // Return null if identifier is not present (backward compat with older plugins)
68
311
  return state.app?.identifier ?? null;
69
312
  }
@@ -84,113 +327,23 @@ async function fetchAppIdentifier() {
84
327
  * @param action - 'start', 'stop', or 'status'
85
328
  * @param host - Optional host address (defaults to env var or localhost)
86
329
  * @param port - Optional port number (defaults to 9223)
330
+ * @param appIdentifier - Optional app identifier for 'stop' action (port or bundle ID)
87
331
  * @returns For 'start'/'stop': A message string describing the result.
88
- * For 'status': A JSON string with connection details including:
89
- * - `connected`: boolean indicating if connected
90
- * - `app`: app name (or null if not connected)
91
- * - `identifier`: app bundle ID (e.g., "com.example.app"), or null
92
- * - `host`: connected host (or null)
93
- * - `port`: connected port (or null)
332
+ * For 'status': A JSON string with connection details
94
333
  */
95
- export async function manageDriverSession(action, host, port) {
96
- // Handle status action
97
- if (action === 'status') {
98
- const client = getPluginClient();
99
- if (client.isConnected() && currentSession) {
100
- return JSON.stringify({
101
- connected: true,
102
- app: currentSession.name,
103
- identifier: currentSession.identifier,
104
- host: currentSession.host,
105
- port: currentSession.port,
106
- });
107
- }
108
- return JSON.stringify({
109
- connected: false,
110
- app: null,
111
- identifier: null,
112
- host: null,
113
- port: null,
114
- });
115
- }
116
- if (action === 'start') {
117
- // Reset any existing connections to ensure fresh connection
118
- if (appDiscovery) {
119
- await appDiscovery.disconnectAll();
334
+ export async function manageDriverSession(action, host, port, appIdentifier) {
335
+ switch (action) {
336
+ case 'status': {
337
+ return handleStatusAction();
120
338
  }
121
- resetPluginClient();
122
- const configuredHost = host ?? getDefaultHost();
123
- const configuredPort = port ?? getDefaultPort();
124
- // Strategy 1: Try localhost first (most reliable)
125
- if (configuredHost !== 'localhost' && configuredHost !== '127.0.0.1') {
126
- try {
127
- const session = await tryConnect('localhost', configuredPort);
128
- // Connect the singleton pluginClient so status checks work
129
- await connectPlugin(session.host, session.port);
130
- // Fetch app identifier after singleton is connected
131
- const identifier = await fetchAppIdentifier();
132
- currentSession = { ...session, identifier };
133
- return `Session started with app: ${session.name} (localhost:${session.port})`;
134
- }
135
- catch {
136
- // Localhost failed, will try configured host next
137
- }
339
+ case 'start': {
340
+ return handleStartAction(host, port);
138
341
  }
139
- // Strategy 2: Try the configured/provided host
140
- try {
141
- const session = await tryConnect(configuredHost, 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} (${session.host}:${session.port})`;
342
+ case 'stop': {
343
+ return handleStopAction(appIdentifier);
148
344
  }
149
- catch {
150
- // Configured host failed
151
- }
152
- // Strategy 3: Auto-discover on localhost (scan port range)
153
- const localhostDiscovery = getAppDiscovery('localhost');
154
- const firstApp = await localhostDiscovery.getFirstAvailableApp();
155
- if (firstApp) {
156
- try {
157
- // Reset client again to connect to discovered port
158
- resetPluginClient();
159
- const session = await tryConnect('localhost', firstApp.port);
160
- // Connect the singleton pluginClient so status checks work
161
- await connectPlugin(session.host, session.port);
162
- // Fetch app identifier after singleton is connected
163
- const identifier = await fetchAppIdentifier();
164
- currentSession = { ...session, identifier };
165
- return `Session started with app: ${session.name} (localhost:${session.port})`;
166
- }
167
- catch {
168
- // Discovery found app but connection failed
169
- }
170
- }
171
- // Strategy 4: Try default port on configured host as last resort
172
- try {
173
- resetPluginClient();
174
- const session = await tryConnect(configuredHost, configuredPort);
175
- // Connect the singleton pluginClient so status checks work
176
- await connectPlugin(session.host, session.port);
177
- // Fetch app identifier after singleton is connected
178
- const identifier = await fetchAppIdentifier();
179
- currentSession = { ...session, identifier };
180
- return `Session started with app: ${session.name} (${session.host}:${session.port})`;
181
- }
182
- catch {
183
- // All attempts failed
184
- currentSession = null;
185
- return `Session started (native IPC mode - no Tauri app found at localhost or ${configuredHost}:${configuredPort})`;
345
+ default: {
346
+ return handleStopAction(appIdentifier);
186
347
  }
187
348
  }
188
- // Stop action - disconnect all apps and reset initialization state
189
- if (appDiscovery) {
190
- await appDiscovery.disconnectAll();
191
- }
192
- resetPluginClient();
193
- resetInitialization();
194
- currentSession = null;
195
- return 'Session stopped';
196
349
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { getPluginClient, connectPlugin } from './plugin-client.js';
2
+ import { connectPlugin } from './plugin-client.js';
3
+ import { hasActiveSession, getDefaultSession, resolveTargetApp } from './session-manager.js';
3
4
  import { createMcpLogger } from '../logger.js';
4
5
  import { buildScreenshotScript, buildScreenshotCaptureScript, getHtml2CanvasSource, HTML2CANVAS_SCRIPT_ID, } from './scripts/html2canvas-loader.js';
5
6
  import { registerScript, isScriptRegistered } from './script-manager.js';
@@ -22,17 +23,27 @@ const driverLogger = createMcpLogger('DRIVER');
22
23
  * This is called automatically by all tool functions.
23
24
  *
24
25
  * Initialization includes:
25
- * - Connecting to the plugin WebSocket
26
+ * - Verifying an active session exists (via tauri_driver_session)
27
+ * - Connecting to the plugin WebSocket using session config
26
28
  * - Console capture is already initialized by bridge.js in the Tauri app
27
29
  *
28
30
  * This function is idempotent - calling it multiple times is safe.
31
+ *
32
+ * @throws Error if no session is active (tauri_driver_session must be called first)
29
33
  */
30
34
  export async function ensureReady() {
31
35
  if (isInitialized) {
32
36
  return;
33
37
  }
34
- // Connect to the plugin
35
- await connectPlugin();
38
+ // Require an active session to prevent connecting to wrong app
39
+ if (!hasActiveSession()) {
40
+ throw new Error('No active session. Call tauri_driver_session with action "start" first to connect to a Tauri app.');
41
+ }
42
+ // Get default session for initial connection
43
+ const session = getDefaultSession();
44
+ if (session) {
45
+ await connectPlugin(session.host, session.port);
46
+ }
36
47
  isInitialized = true;
37
48
  }
38
49
  /**
@@ -46,10 +57,11 @@ export function resetInitialization() {
46
57
  *
47
58
  * @param script - JavaScript code to execute in the webview context
48
59
  * @param windowId - Optional window label to target (defaults to "main")
60
+ * @param appIdentifier - Optional app identifier to target specific app
49
61
  * @returns Result of the script execution with window context
50
62
  */
51
- export async function executeInWebview(script, windowId) {
52
- const { result } = await executeInWebviewWithContext(script, windowId);
63
+ export async function executeInWebview(script, windowId, appIdentifier) {
64
+ const { result } = await executeInWebviewWithContext(script, windowId, appIdentifier);
53
65
  return result;
54
66
  }
55
67
  /**
@@ -57,13 +69,16 @@ export async function executeInWebview(script, windowId) {
57
69
  *
58
70
  * @param script - JavaScript code to execute in the webview context
59
71
  * @param windowId - Optional window label to target (defaults to "main")
72
+ * @param appIdentifier - Optional app identifier to target specific app
60
73
  * @returns Result of the script execution with window context
61
74
  */
62
- export async function executeInWebviewWithContext(script, windowId) {
75
+ export async function executeInWebviewWithContext(script, windowId, appIdentifier) {
63
76
  try {
64
77
  // Ensure we're fully initialized
65
78
  await ensureReady();
66
- const client = getPluginClient();
79
+ // Resolve target session
80
+ const session = resolveTargetApp(appIdentifier);
81
+ const client = session.client;
67
82
  // Send script directly - Rust handles wrapping and IPC callbacks.
68
83
  // Use 7s timeout (longer than Rust's 5s) so errors return before Node times out.
69
84
  const response = await client.sendCommand({
@@ -169,9 +184,11 @@ export async function initializeConsoleCapture() {
169
184
  *
170
185
  * @param filter - Optional regex pattern to filter log messages
171
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
172
189
  * @returns Formatted console logs as string
173
190
  */
174
- export async function getConsoleLogs(filter, since) {
191
+ export async function getConsoleLogs(filter, since, windowId, appIdentifier) {
175
192
  const filterStr = filter ? filter.replace(/'/g, '\\\'') : '';
176
193
  const sinceStr = since || '';
177
194
  const script = `
@@ -196,7 +213,7 @@ export async function getConsoleLogs(filter, since) {
196
213
  '[ ' + new Date(l.timestamp).toISOString() + ' ] [ ' + l.level.toUpperCase() + ' ] ' + l.message
197
214
  ).join('\\n');
198
215
  `;
199
- return executeInWebview(script);
216
+ return executeInWebview(script, windowId, appIdentifier);
200
217
  }
201
218
  /**
202
219
  * Clear all captured console logs.
@@ -268,11 +285,11 @@ async function prepareHtml2canvasScript(format, quality) {
268
285
  /**
269
286
  * Capture a screenshot of the entire webview.
270
287
  *
271
- * @param options - Screenshot options (format, quality, windowId)
288
+ * @param options - Screenshot options (format, quality, windowId, appIdentifier)
272
289
  * @returns Screenshot result with image content
273
290
  */
274
291
  export async function captureScreenshot(options = {}) {
275
- const { format = 'png', quality = 90, windowId } = options;
292
+ const { format = 'png', quality = 90, windowId, appIdentifier } = options;
276
293
  // Primary implementation: Use native platform-specific APIs
277
294
  // - macOS: WKWebView takeSnapshot
278
295
  // - Windows: WebView2 CapturePreview
@@ -280,7 +297,9 @@ export async function captureScreenshot(options = {}) {
280
297
  try {
281
298
  // Ensure we're fully initialized
282
299
  await ensureReady();
283
- const client = getPluginClient();
300
+ // Resolve target session
301
+ const session = resolveTargetApp(appIdentifier);
302
+ const client = session.client;
284
303
  // Use longer timeout (15s) for native screenshot - the Rust code waits up to 10s
285
304
  const response = await client.sendCommand({
286
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 applications.
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);
@@ -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
@@ -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
- 'Use action "status" to check current connection state and get the app identifier. ' +
140
- 'The status response includes an "identifier" field (e.g., "com.example.myapp") that uniquely identifies the connected app. ' +
141
- 'The identifier may be null if the Tauri app uses an older plugin version that does not provide it. ' +
142
- 'Before starting a new session, check status first - if already connected to the correct app (matching identifier), ' +
143
- 'reuse the existing session. If identifier is null, you cannot verify the app identity. ' +
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(parsed.command, parsed.args);
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
- return await getBackendState();
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.5.0",
4
- "description": "A Model Context Protocol server for Tauri v2 development",
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"