@hypothesi/tauri-mcp-server 0.11.2 → 0.13.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.
@@ -30,10 +30,18 @@ export declare const ManageDriverSessionSchema: z.ZodObject<{
30
30
  export interface SessionInfo {
31
31
  name: string;
32
32
  identifier: string | null;
33
+ /**
34
+ * Working directory of the host Tauri process at session-start time.
35
+ * `null` when the plugin is too old to advertise it (pre-v0.11) or
36
+ * when the lookup failed; in that case CWD-based routing skips this
37
+ * session and falls back to default behavior.
38
+ */
39
+ cwd: string | null;
33
40
  host: string;
34
41
  port: number;
35
42
  client: PluginClient;
36
43
  connected: boolean;
44
+ pluginVersion: string | null;
37
45
  }
38
46
  /**
39
47
  * Check if any session is currently active.
@@ -52,9 +60,40 @@ export declare function getDefaultSession(): SessionInfo | null;
52
60
  * Get all active sessions.
53
61
  */
54
62
  export declare function getAllSessions(): SessionInfo[];
63
+ /**
64
+ * Find the session whose `cwd` best matches `hintCwd`.
65
+ *
66
+ * Used to disambiguate multiple concurrent Tauri instances when the caller
67
+ * did not pass an explicit appIdentifier. The "best match" is the longest
68
+ * shared prefix between the hint and a session's CWD, in either direction:
69
+ *
70
+ * - `session.cwd === hintCwd` (exact)
71
+ * - `hintCwd.startsWith(session.cwd + '/')` (TS server runs inside a
72
+ * subdirectory of the host process's CWD; rare)
73
+ * - `session.cwd.startsWith(hintCwd + '/')` (host process runs inside
74
+ * the workspace the TS server was launched from; the common case
75
+ * when VSCode opens a parent dir and moss runs from a worktree)
76
+ *
77
+ * Sessions whose `cwd` is null (older plugin without CWD support) are
78
+ * skipped — they cannot be matched and stay eligible for the existing
79
+ * "use default app" fallback in `resolveTargetApp`.
80
+ *
81
+ * Pure function (takes `sessions` as a parameter) so it can be unit-tested
82
+ * without touching module state.
83
+ *
84
+ * @returns The best-matching session, or null if no session matches.
85
+ */
86
+ export declare function findSessionByCwd(sessions: Iterable<SessionInfo>, hintCwd: string | null): SessionInfo | null;
55
87
  /**
56
88
  * Resolve target app from port or identifier.
57
89
  * Returns the appropriate session based on the routing logic.
90
+ *
91
+ * Order of precedence:
92
+ * 1. Explicit `portOrIdentifier` (port number or bundle id)
93
+ * 2. CWD match via `findSessionByCwd` against `MCP_BRIDGE_CWD` or
94
+ * `process.cwd()` — lets each MCP client target the Tauri instance
95
+ * running in its own workspace/worktree without manual routing
96
+ * 3. The default app (most-recently-connected)
58
97
  */
59
98
  export declare function resolveTargetApp(portOrIdentifier?: string | number): SessionInfo;
60
99
  /**
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { getDefaultHost, getDefaultPort } from '../config.js';
2
+ import { getCwdHint, getDefaultHost, getDefaultPort } from '../config.js';
3
3
  import { AppDiscovery } from './app-discovery.js';
4
4
  import { PluginClient } from './plugin-client.js';
5
5
  import { resetInitialization } from './webview-executor.js';
6
6
  import { createMcpLogger } from '../logger.js';
7
+ import { PLUGIN_VERSION_FULL, describeVersionSkew } from '../version.js';
7
8
  const sessionLogger = createMcpLogger('SESSION');
8
9
  /**
9
10
  * Session Manager - Native IPC-based session management
@@ -69,9 +70,65 @@ function getAppDiscovery(host) {
69
70
  }
70
71
  return appDiscovery;
71
72
  }
73
+ /**
74
+ * Find the session whose `cwd` best matches `hintCwd`.
75
+ *
76
+ * Used to disambiguate multiple concurrent Tauri instances when the caller
77
+ * did not pass an explicit appIdentifier. The "best match" is the longest
78
+ * shared prefix between the hint and a session's CWD, in either direction:
79
+ *
80
+ * - `session.cwd === hintCwd` (exact)
81
+ * - `hintCwd.startsWith(session.cwd + '/')` (TS server runs inside a
82
+ * subdirectory of the host process's CWD; rare)
83
+ * - `session.cwd.startsWith(hintCwd + '/')` (host process runs inside
84
+ * the workspace the TS server was launched from; the common case
85
+ * when VSCode opens a parent dir and moss runs from a worktree)
86
+ *
87
+ * Sessions whose `cwd` is null (older plugin without CWD support) are
88
+ * skipped — they cannot be matched and stay eligible for the existing
89
+ * "use default app" fallback in `resolveTargetApp`.
90
+ *
91
+ * Pure function (takes `sessions` as a parameter) so it can be unit-tested
92
+ * without touching module state.
93
+ *
94
+ * @returns The best-matching session, or null if no session matches.
95
+ */
96
+ export function findSessionByCwd(sessions, hintCwd) {
97
+ if (!hintCwd) {
98
+ return null;
99
+ }
100
+ let best = null, bestScore = -1;
101
+ for (const session of sessions) {
102
+ if (!session.cwd) {
103
+ continue;
104
+ }
105
+ let score = -1;
106
+ if (session.cwd === hintCwd) {
107
+ score = session.cwd.length;
108
+ }
109
+ else if (hintCwd.startsWith(session.cwd + '/')) {
110
+ score = session.cwd.length;
111
+ }
112
+ else if (session.cwd.startsWith(hintCwd + '/')) {
113
+ score = hintCwd.length;
114
+ }
115
+ if (score > bestScore) {
116
+ best = session;
117
+ bestScore = score;
118
+ }
119
+ }
120
+ return best;
121
+ }
72
122
  /**
73
123
  * Resolve target app from port or identifier.
74
124
  * Returns the appropriate session based on the routing logic.
125
+ *
126
+ * Order of precedence:
127
+ * 1. Explicit `portOrIdentifier` (port number or bundle id)
128
+ * 2. CWD match via `findSessionByCwd` against `MCP_BRIDGE_CWD` or
129
+ * `process.cwd()` — lets each MCP client target the Tauri instance
130
+ * running in its own workspace/worktree without manual routing
131
+ * 3. The default app (most-recently-connected)
75
132
  */
76
133
  export function resolveTargetApp(portOrIdentifier) {
77
134
  if (activeSessions.size === 0) {
@@ -105,6 +162,12 @@ export function resolveTargetApp(portOrIdentifier) {
105
162
  }
106
163
  throw new Error(formatAppNotFoundError(portOrIdentifier));
107
164
  }
165
+ // No explicit identifier: try CWD-based routing before falling back to
166
+ // "default app". If no session matches, behave as before.
167
+ const cwdMatch = findSessionByCwd(activeSessions.values(), getCwdHint());
168
+ if (cwdMatch) {
169
+ return cwdMatch;
170
+ }
108
171
  // Use default app
109
172
  if (defaultPort !== null && activeSessions.has(defaultPort)) {
110
173
  const session = activeSessions.get(defaultPort);
@@ -149,6 +212,30 @@ function promoteNextDefault() {
149
212
  defaultPort = null;
150
213
  }
151
214
  }
215
+ /**
216
+ * Return whether a cached session is responsive, removing it when stale.
217
+ */
218
+ async function keepResponsiveSession(port) {
219
+ const session = activeSessions.get(port);
220
+ if (!session) {
221
+ return false;
222
+ }
223
+ try {
224
+ if (await session.client.checkConnection()) {
225
+ return true;
226
+ }
227
+ }
228
+ catch {
229
+ // Treat probe errors like missed pongs and replace the stale client below.
230
+ }
231
+ session.client.disconnect();
232
+ activeSessions.delete(port);
233
+ if (port === defaultPort) {
234
+ promoteNextDefault();
235
+ }
236
+ sessionLogger.info(`Removed stale session: ${session.name} (${session.host}:${session.port})`);
237
+ return false;
238
+ }
152
239
  async function handleStatusAction() {
153
240
  if (activeSessions.size === 0) {
154
241
  return JSON.stringify({
@@ -157,6 +244,9 @@ async function handleStatusAction() {
157
244
  identifier: null,
158
245
  host: null,
159
246
  port: null,
247
+ pluginVersion: null,
248
+ serverVersion: PLUGIN_VERSION_FULL,
249
+ versionWarning: describeVersionSkew(null),
160
250
  });
161
251
  }
162
252
  if (activeSessions.size === 1) {
@@ -168,22 +258,35 @@ async function handleStatusAction() {
168
258
  identifier: null,
169
259
  host: null,
170
260
  port: null,
261
+ pluginVersion: null,
262
+ serverVersion: PLUGIN_VERSION_FULL,
263
+ versionWarning: describeVersionSkew(null),
171
264
  });
172
265
  }
266
+ const versionWarning = describeVersionSkew(session.pluginVersion);
173
267
  return JSON.stringify({
174
268
  connected: true,
175
269
  app: session.name,
176
270
  identifier: session.identifier,
271
+ cwd: session.cwd,
177
272
  host: session.host,
178
273
  port: session.port,
274
+ pluginVersion: session.pluginVersion,
275
+ serverVersion: PLUGIN_VERSION_FULL,
276
+ versionWarning,
179
277
  });
180
278
  }
181
279
  const apps = Array.from(activeSessions.values()).map((session) => {
280
+ const versionWarning = describeVersionSkew(session.pluginVersion);
182
281
  return {
183
282
  name: session.name,
184
283
  identifier: session.identifier,
284
+ cwd: session.cwd,
185
285
  host: session.host,
186
286
  port: session.port,
287
+ pluginVersion: session.pluginVersion,
288
+ serverVersion: PLUGIN_VERSION_FULL,
289
+ versionWarning,
187
290
  isDefault: session.port === defaultPort,
188
291
  };
189
292
  });
@@ -192,12 +295,13 @@ async function handleStatusAction() {
192
295
  apps,
193
296
  totalCount: apps.length,
194
297
  defaultPort,
298
+ serverVersion: PLUGIN_VERSION_FULL,
195
299
  });
196
300
  }
197
301
  async function handleStartAction(host, port) {
198
302
  const configuredHost = host ?? getDefaultHost();
199
303
  const configuredPort = port ?? getDefaultPort();
200
- if (activeSessions.has(configuredPort)) {
304
+ if (await keepResponsiveSession(configuredPort)) {
201
305
  return `Already connected to app on port ${configuredPort}`;
202
306
  }
203
307
  let connectedSession = null;
@@ -232,21 +336,31 @@ async function handleStartAction(host, port) {
232
336
  if (!connectedSession) {
233
337
  return `Session start failed - no Tauri app found at localhost or ${configuredHost}:${configuredPort}`;
234
338
  }
339
+ if (await keepResponsiveSession(connectedSession.port)) {
340
+ return `Already connected to app on port ${connectedSession.port}`;
341
+ }
235
342
  const client = new PluginClient(connectedSession.host, connectedSession.port);
236
343
  await client.connect();
237
- const identifier = await fetchAppIdentifier(client);
344
+ const { identifier, cwd, pluginVersion } = await fetchAppMetadata(client);
238
345
  const sessionInfo = {
239
346
  name: connectedSession.name,
240
347
  identifier,
348
+ cwd,
241
349
  host: connectedSession.host,
242
350
  port: connectedSession.port,
243
351
  client,
244
352
  connected: true,
353
+ pluginVersion,
245
354
  };
246
355
  activeSessions.set(connectedSession.port, sessionInfo);
247
356
  defaultPort = connectedSession.port;
248
357
  sessionLogger.info(`Session started: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`);
249
- return `Session started with app: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`;
358
+ let result = `Session started with app: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`;
359
+ const versionWarning = describeVersionSkew(sessionInfo.pluginVersion);
360
+ if (versionWarning) {
361
+ result = `${versionWarning}\n\n${result}`;
362
+ }
363
+ return result;
250
364
  }
251
365
  async function handleStopAction(appIdentifier) {
252
366
  if (appIdentifier !== undefined) {
@@ -292,27 +406,33 @@ async function tryConnect(host, port) {
292
406
  * Must be called after a PluginClient is connected.
293
407
  *
294
408
  * @param client - The PluginClient to query
295
- * @returns The app identifier (bundle ID) or null if not available. Returns null when:
296
- * - The plugin doesn't support the identifier field (older versions)
409
+ * @returns `{ identifier, cwd }`. Each field is null when:
410
+ * - The plugin is too old to advertise it
297
411
  * - The backend state request fails
298
- * - The identifier field is missing from the response
412
+ * - The field is missing from the response
413
+ *
414
+ * Older plugins (pre-identifier) return `{ identifier: null, cwd: null }`.
415
+ * Newer plugins without the cwd field return a real identifier and a null cwd.
416
+ * Routing degrades gracefully in either case.
299
417
  */
300
- async function fetchAppIdentifier(client) {
418
+ async function fetchAppMetadata(client) {
301
419
  try {
302
420
  const response = await client.sendCommand({
303
421
  command: 'invoke_tauri',
304
422
  args: { command: 'plugin:mcp-bridge|get_backend_state', args: {} },
305
423
  });
306
424
  if (!response.success || !response.data) {
307
- return null;
425
+ return { identifier: null, cwd: null, pluginVersion: null };
308
426
  }
309
427
  const state = response.data;
310
- // Return null if identifier is not present (backward compat with older plugins)
311
- return state.app?.identifier ?? null;
428
+ return {
429
+ identifier: state.app?.identifier ?? null,
430
+ cwd: typeof state.cwd === 'string' && state.cwd.length > 0 ? state.cwd : null,
431
+ pluginVersion: state.bridge?.pluginVersion ?? null,
432
+ };
312
433
  }
313
434
  catch {
314
- // Return null on any error (e.g., older plugin version that doesn't support this)
315
- return null;
435
+ return { identifier: null, cwd: null, pluginVersion: null };
316
436
  }
317
437
  }
318
438
  /**
@@ -28,18 +28,20 @@ export interface ExecuteInWebviewResult {
28
28
  * @param script - JavaScript code to execute in the webview context
29
29
  * @param windowId - Optional window label to target (defaults to "main")
30
30
  * @param appIdentifier - Optional app identifier to target specific app
31
+ * @param timeoutMs - Optional timeout in milliseconds
31
32
  * @returns Result of the script execution with window context
32
33
  */
33
- export declare function executeInWebview(script: string, windowId?: string, appIdentifier?: string | number): Promise<string>;
34
+ export declare function executeInWebview(script: string, windowId?: string, appIdentifier?: string | number, timeoutMs?: number): Promise<string>;
34
35
  /**
35
36
  * Execute JavaScript in the Tauri webview and return window context.
36
37
  *
37
38
  * @param script - JavaScript code to execute in the webview context
38
39
  * @param windowId - Optional window label to target (defaults to "main")
39
40
  * @param appIdentifier - Optional app identifier to target specific app
41
+ * @param timeoutMs - Optional timeout in milliseconds
40
42
  * @returns Result of the script execution with window context
41
43
  */
42
- export declare function executeInWebviewWithContext(script: string, windowId?: string, appIdentifier?: string | number): Promise<ExecuteInWebviewResult>;
44
+ export declare function executeInWebviewWithContext(script: string, windowId?: string, appIdentifier?: string | number, timeoutMs?: number, isRetry?: boolean): Promise<ExecuteInWebviewResult>;
43
45
  /**
44
46
  * Execute async JavaScript in the webview with timeout support.
45
47
  *
@@ -61,13 +63,19 @@ export declare function initializeConsoleCapture(): Promise<string>;
61
63
  /**
62
64
  * Retrieve captured console logs with optional filtering.
63
65
  *
64
- * @param filter - Optional regex pattern to filter log messages
65
- * @param since - Optional ISO timestamp to filter logs after this time
66
- * @param windowId - Optional window label to target (defaults to "main")
67
- * @param appIdentifier - Optional app identifier to target specific app
66
+ * @param options - Filtering and formatting options
68
67
  * @returns Formatted console logs as string
69
68
  */
70
- export declare function getConsoleLogs(filter?: string, since?: string, windowId?: string, appIdentifier?: string | number): Promise<string>;
69
+ export declare function getConsoleLogs(options?: {
70
+ filter?: string;
71
+ since?: string;
72
+ lines?: number;
73
+ windowId?: string;
74
+ appIdentifier?: string | number;
75
+ level?: string;
76
+ maxChars?: number;
77
+ maxCharsPerEntry?: number;
78
+ }): Promise<string>;
71
79
  /**
72
80
  * Clear all captured console logs.
73
81
  */
@@ -85,9 +93,10 @@ export interface CaptureScreenshotOptions {
85
93
  windowId?: string;
86
94
  appIdentifier?: string | number;
87
95
  maxWidth?: number;
96
+ allowScreenCapture?: boolean;
88
97
  }
89
98
  /**
90
- * Capture a screenshot of the entire webview.
99
+ * Capture a screenshot of the visible webview viewport.
91
100
  *
92
101
  * @param options - Screenshot options (format, quality, windowId, appIdentifier, etc.)
93
102
  * @returns Screenshot result with image content
@@ -113,10 +122,13 @@ export declare const GetConsoleLogsSchema: z.ZodObject<{
113
122
  export declare const CaptureScreenshotSchema: z.ZodObject<{
114
123
  format: z.ZodDefault<z.ZodOptional<z.ZodEnum<["png", "jpeg"]>>>;
115
124
  quality: z.ZodOptional<z.ZodNumber>;
125
+ allowScreenCapture: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
116
126
  }, "strip", z.ZodTypeAny, {
117
127
  format: "png" | "jpeg";
128
+ allowScreenCapture: boolean;
118
129
  quality?: number | undefined;
119
130
  }, {
120
131
  format?: "png" | "jpeg" | undefined;
121
132
  quality?: number | undefined;
133
+ allowScreenCapture?: boolean | undefined;
122
134
  }>;