@hypothesi/tauri-mcp-server 0.12.0 → 0.13.1

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.
@@ -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
  }>;
@@ -18,6 +18,14 @@ import { getResolveRefSource, RESOLVE_REF_SCRIPT_ID } from './scripts/index.js';
18
18
  // ============================================================================
19
19
  const initializedTargets = new Set();
20
20
  const driverLogger = createMcpLogger('DRIVER');
21
+ const MCP_HELPER_MISSING = /__MCP__|resolveRef|resolveAll|countAll/;
22
+ function isHelperMissingError(message) {
23
+ return MCP_HELPER_MISSING.test(message) &&
24
+ /undefined is not an object|is not a function|Cannot read propert/.test(message);
25
+ }
26
+ function targetKeyFor(session, windowId) {
27
+ return `${session.host}:${session.port}:${windowId ?? 'main'}`;
28
+ }
21
29
  /**
22
30
  * Ensures the MCP server is fully initialized and ready to use.
23
31
  * This is called automatically by all tool functions.
@@ -43,7 +51,7 @@ export async function ensureReady(windowId, appIdentifier) {
43
51
  if (!session.client.isConnected()) {
44
52
  await session.client.connect();
45
53
  }
46
- const targetKey = `${session.host}:${session.port}:${windowId ?? 'main'}`;
54
+ const targetKey = targetKeyFor(session, windowId);
47
55
  if (initializedTargets.has(targetKey)) {
48
56
  return;
49
57
  }
@@ -84,10 +92,11 @@ async function waitForResolveRefHelper(session, windowId) {
84
92
  * @param script - JavaScript code to execute in the webview context
85
93
  * @param windowId - Optional window label to target (defaults to "main")
86
94
  * @param appIdentifier - Optional app identifier to target specific app
95
+ * @param timeoutMs - Optional timeout in milliseconds
87
96
  * @returns Result of the script execution with window context
88
97
  */
89
- export async function executeInWebview(script, windowId, appIdentifier) {
90
- const { result } = await executeInWebviewWithContext(script, windowId, appIdentifier);
98
+ export async function executeInWebview(script, windowId, appIdentifier, timeoutMs) {
99
+ const { result } = await executeInWebviewWithContext(script, windowId, appIdentifier, timeoutMs);
91
100
  return result;
92
101
  }
93
102
  /**
@@ -96,21 +105,38 @@ export async function executeInWebview(script, windowId, appIdentifier) {
96
105
  * @param script - JavaScript code to execute in the webview context
97
106
  * @param windowId - Optional window label to target (defaults to "main")
98
107
  * @param appIdentifier - Optional app identifier to target specific app
108
+ * @param timeoutMs - Optional timeout in milliseconds
99
109
  * @returns Result of the script execution with window context
100
110
  */
101
- export async function executeInWebviewWithContext(script, windowId, appIdentifier) {
111
+ export async function executeInWebviewWithContext(script, windowId, appIdentifier, timeoutMs, isRetry = false) {
102
112
  try {
103
- // Ensure we're fully initialized
104
- await ensureReady(windowId, appIdentifier);
105
- // Resolve target session
113
+ return await runInWebview(script, windowId, appIdentifier, timeoutMs);
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ if (isRetry || !isHelperMissingError(message)) {
118
+ throw new Error(`WebView execution failed: ${message}`);
119
+ }
120
+ driverLogger.warn(`__MCP__ helper missing, re-injecting and retrying once: ${message}`);
106
121
  const session = resolveTargetApp(appIdentifier);
107
- const client = session.client;
108
- // Send script directly - Rust handles wrapping and IPC callbacks.
109
- // Use 7s timeout (longer than Rust's 5s) so errors return before Node times out.
122
+ initializedTargets.delete(targetKeyFor(session, windowId));
123
+ await ensureReady(windowId, appIdentifier);
124
+ return executeInWebviewWithContext(script, windowId, appIdentifier, timeoutMs, true);
125
+ }
126
+ }
127
+ async function runInWebview(script, windowId, appIdentifier, timeoutMs) {
128
+ // Ensure we're fully initialized
129
+ await ensureReady(windowId, appIdentifier);
130
+ // Resolve target session
131
+ const session = resolveTargetApp(appIdentifier);
132
+ const client = session.client;
133
+ // Send script directly - Rust handles wrapping and IPC callbacks.
134
+ // Use timeoutMs if provided, otherwise default 7s timeout (longer than Rust's 5s)
135
+ try {
110
136
  const response = await client.sendCommand({
111
137
  command: 'execute_js',
112
138
  args: { script, windowLabel: windowId },
113
- }, 7000);
139
+ }, timeoutMs ?? 7000);
114
140
  if (!response.success) {
115
141
  throw new Error(response.error || 'Unknown execution error');
116
142
  }
@@ -136,7 +162,34 @@ export async function executeInWebviewWithContext(script, windowId, appIdentifie
136
162
  }
137
163
  catch (error) {
138
164
  const message = error instanceof Error ? error.message : String(error);
139
- throw new Error(`WebView execution failed: ${message}`);
165
+ // Check for transport timeout
166
+ if (/^Request timeout after \d+ms$/.test(message)) {
167
+ const probeResult = await probeBridge(session, windowId);
168
+ if (probeResult) {
169
+ throw new Error(`Bridge did not respond within ${timeoutMs ?? 7000}ms. A follow-up probe succeeded, so the bridge is alive and this ` +
170
+ 'specific script was too slow. Raise `timeout`, or split the work across calls.');
171
+ }
172
+ else {
173
+ throw new Error(`Bridge did not respond within ${timeoutMs ?? 7000}ms and a follow-up probe also timed out. The webview is wedged. ` +
174
+ 'Call driver_session with action "stop" then "start". A runaway loop in a previous script is the usual cause.');
175
+ }
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+ async function probeBridge(session, windowId) {
181
+ if (!session) {
182
+ return false;
183
+ }
184
+ try {
185
+ const response = await session.client.sendCommand({
186
+ command: 'execute_js',
187
+ args: { script: 'return 1', windowLabel: windowId },
188
+ }, 2000);
189
+ return response.success === true;
190
+ }
191
+ catch {
192
+ return false;
140
193
  }
141
194
  }
142
195
  /**
@@ -149,6 +202,7 @@ export async function executeInWebviewWithContext(script, windowId, appIdentifie
149
202
  */
150
203
  export async function executeAsyncInWebview(script, windowId, timeout, appIdentifier) {
151
204
  const resolvedTimeout = timeout ?? 5000;
205
+ const transportTimeout = resolvedTimeout + 2000;
152
206
  const wrappedScript = `
153
207
  return (async () => {
154
208
  const timeoutPromise = new Promise((_, reject) => {
@@ -162,7 +216,7 @@ export async function executeAsyncInWebview(script, windowId, timeout, appIdenti
162
216
  return await Promise.race([scriptPromise, timeoutPromise]);
163
217
  })();
164
218
  `;
165
- return executeInWebview(wrappedScript, windowId, appIdentifier);
219
+ return executeInWebview(wrappedScript, windowId, appIdentifier, transportTimeout);
166
220
  }
167
221
  // ============================================================================
168
222
  // Console Log Capture System
@@ -209,14 +263,12 @@ export async function initializeConsoleCapture() {
209
263
  /**
210
264
  * Retrieve captured console logs with optional filtering.
211
265
  *
212
- * @param filter - Optional regex pattern to filter log messages
213
- * @param since - Optional ISO timestamp to filter logs after this time
214
- * @param windowId - Optional window label to target (defaults to "main")
215
- * @param appIdentifier - Optional app identifier to target specific app
266
+ * @param options - Filtering and formatting options
216
267
  * @returns Formatted console logs as string
217
268
  */
218
- export async function getConsoleLogs(filter, since, windowId, appIdentifier) {
219
- const filterStr = filter ? filter.replace(/'/g, '\\\'') : '';
269
+ export async function getConsoleLogs(options = {}) {
270
+ const { filter, since, lines = 50, windowId, appIdentifier, level, maxChars = 20000, maxCharsPerEntry = 2000 } = options;
271
+ const resolvedLines = lines, resolvedMaxChars = maxChars, resolvedMaxCharsPerEntry = maxCharsPerEntry, resolvedLevel = level ?? '', filterStr = filter ? filter.replace(/'/g, '\\\'') : '', levelStr = resolvedLevel;
220
272
  const sinceStr = since || '';
221
273
  const script = `
222
274
  const logs = window.__MCP_CONSOLE_LOGS__ || [];
@@ -227,6 +279,10 @@ export async function getConsoleLogs(filter, since, windowId, appIdentifier) {
227
279
  filtered = filtered.filter(l => l.timestamp > sinceTime);
228
280
  }
229
281
 
282
+ if ('${levelStr}') {
283
+ filtered = filtered.filter(l => l.level === '${levelStr}');
284
+ }
285
+
230
286
  if ('${filterStr}') {
231
287
  try {
232
288
  const regex = new RegExp('${filterStr}', 'i');
@@ -236,9 +292,34 @@ export async function getConsoleLogs(filter, since, windowId, appIdentifier) {
236
292
  }
237
293
  }
238
294
 
239
- return filtered.map(l =>
240
- '[ ' + new Date(l.timestamp).toISOString() + ' ] [ ' + l.level.toUpperCase() + ' ] ' + l.message
241
- ).join('\\n');
295
+ filtered = filtered.slice(-${resolvedLines});
296
+
297
+ var budget = ${resolvedMaxChars};
298
+ var dropped = 0;
299
+ var out = [];
300
+
301
+ for (var i = filtered.length - 1; i >= 0; i--) {
302
+ var entry = filtered[i],
303
+ excess = entry.message.length - ${resolvedMaxCharsPerEntry},
304
+ message = entry.message.length > ${resolvedMaxCharsPerEntry}
305
+ ? entry.message.slice(0, ${resolvedMaxCharsPerEntry}) + '…[' + excess + ' more chars]'
306
+ : entry.message,
307
+ prefix = '[ ' + new Date(entry.timestamp).toISOString() + ' ] [ ' + entry.level.toUpperCase() + ' ] ',
308
+ line = prefix + message;
309
+
310
+ if (line.length > budget) { dropped = i + 1; break; }
311
+ budget -= line.length + 1;
312
+ out.unshift(line);
313
+ }
314
+
315
+ if (dropped > 0) {
316
+ out.unshift(
317
+ '[ ' + dropped + ' older entries dropped to fit maxChars=' + ${resolvedMaxChars} +
318
+ '. Narrow with level, filter, or since. ]'
319
+ );
320
+ }
321
+
322
+ return out.join('\\n');
242
323
  `;
243
324
  return executeInWebview(script, windowId, appIdentifier);
244
325
  }
@@ -265,7 +346,7 @@ function parseDataUrl(dataUrl) {
265
346
  /**
266
347
  * Build screenshot result with image content and optional text context.
267
348
  */
268
- function buildScreenshotResult(dataUrl, method, windowContext) {
349
+ function buildScreenshotResult(dataUrl, method, windowContext, metadata) {
269
350
  const parsed = parseDataUrl(dataUrl);
270
351
  if (!parsed) {
271
352
  throw new Error(`Invalid data URL format: ${dataUrl.substring(0, 50)}...`);
@@ -279,6 +360,16 @@ function buildScreenshotResult(dataUrl, method, windowContext) {
279
360
  contextText += `\n\n⚠️ ${windowContext.warning}`;
280
361
  }
281
362
  }
363
+ // Add screenshot metadata if available
364
+ if (metadata) {
365
+ const scale = metadata.cssWidth > 0 ? metadata.imageWidth / metadata.cssWidth : 0;
366
+ const inverseScale = scale > 0 ? (1 / scale).toFixed(1) : '1.0';
367
+ contextText += `\nImage is ${metadata.imageWidth}x${metadata.imageHeight} px. ` +
368
+ `Webview viewport is ${Math.round(metadata.cssWidth)}x${Math.round(metadata.cssHeight)} CSS px. ` +
369
+ `Scale ${scale.toFixed(3)}.`;
370
+ contextText += '\nwebview_interact x and y are CSS pixels: ' +
371
+ `multiply image coordinates by ${inverseScale}.`;
372
+ }
282
373
  content.push({ type: 'text', text: contextText });
283
374
  // Add the image content
284
375
  content.push({
@@ -309,49 +400,53 @@ async function prepareHtml2canvasScript(format, quality, windowId, appIdentifier
309
400
  return buildScreenshotScript(format, quality);
310
401
  }
311
402
  }
403
+ function parseNativeScreenshotPayload(rawPayload) {
404
+ if (typeof rawPayload === 'string') {
405
+ return { dataUrl: rawPayload };
406
+ }
407
+ const payload = rawPayload, dimensions = [payload.imageWidth, payload.imageHeight, payload.cssWidth, payload.cssHeight], hasMetadata = dimensions.every((value) => { return typeof value === 'number'; });
408
+ if (!hasMetadata) {
409
+ return { dataUrl: payload.dataUrl };
410
+ }
411
+ return {
412
+ dataUrl: payload.dataUrl,
413
+ metadata: {
414
+ imageWidth: payload.imageWidth,
415
+ imageHeight: payload.imageHeight,
416
+ cssWidth: payload.cssWidth,
417
+ cssHeight: payload.cssHeight,
418
+ },
419
+ };
420
+ }
421
+ async function captureNativeScreenshot(options) {
422
+ const { format, quality, windowId, appIdentifier, maxWidth } = options;
423
+ await ensureReady(windowId, appIdentifier);
424
+ const session = resolveTargetApp(appIdentifier), args = { format, quality, windowLabel: windowId, maxWidth }, command = { command: 'capture_native_screenshot', args }, response = await session.client.sendCommand(command, 15000);
425
+ if (!response.success || !response.data) {
426
+ throw new Error(response.error || 'Native screenshot returned invalid data');
427
+ }
428
+ const { dataUrl, metadata } = parseNativeScreenshotPayload(response.data);
429
+ if (!dataUrl || !dataUrl.startsWith('data:image/')) {
430
+ throw new Error('Native screenshot returned invalid data');
431
+ }
432
+ return buildScreenshotResult(dataUrl, 'native API', response.windowContext, metadata);
433
+ }
312
434
  /**
313
- * Capture a screenshot of the entire webview.
435
+ * Capture a screenshot of the visible webview viewport.
314
436
  *
315
437
  * @param options - Screenshot options (format, quality, windowId, appIdentifier, etc.)
316
438
  * @returns Screenshot result with image content
317
439
  */
318
440
  export async function captureScreenshot(options = {}) {
319
- const { format = 'jpeg', quality = 80, windowId, appIdentifier, maxWidth } = options;
320
- // Primary implementation: Use native platform-specific APIs
321
- // - macOS: WKWebView takeSnapshot
322
- // - Windows: WebView2 CapturePreview
323
- // - Linux: Chromium/WebKit screenshot APIs
441
+ const { format = 'jpeg', quality = 80, windowId, appIdentifier, maxWidth, allowScreenCapture = false, } = options;
442
+ let nativeErrorMsg = 'Native screenshot unavailable';
324
443
  try {
325
- // Ensure we're fully initialized
326
- await ensureReady(windowId, appIdentifier);
327
- // Resolve target session
328
- const session = resolveTargetApp(appIdentifier);
329
- const client = session.client;
330
- // Use longer timeout (15s) for native screenshot - the Rust code waits up to 10s
331
- const response = await client.sendCommand({
332
- command: 'capture_native_screenshot',
333
- args: {
334
- format,
335
- quality,
336
- windowLabel: windowId,
337
- maxWidth,
338
- },
339
- }, 15000);
340
- if (!response.success || !response.data) {
341
- throw new Error(response.error || 'Native screenshot returned invalid data');
342
- }
343
- // The native command returns a base64 data URL
344
- const dataUrl = response.data;
345
- if (!dataUrl || !dataUrl.startsWith('data:image/')) {
346
- throw new Error('Native screenshot returned invalid data');
347
- }
348
- // Build response with window context
349
- return buildScreenshotResult(dataUrl, 'native API', response.windowContext);
444
+ return await captureNativeScreenshot({ format, quality, windowId, appIdentifier, maxWidth });
350
445
  }
351
446
  catch (nativeError) {
352
447
  // Log the native error for debugging, then fall back
353
- const nativeMsg = nativeError instanceof Error ? nativeError.message : String(nativeError);
354
- driverLogger.error(`Native screenshot failed: ${nativeMsg}, falling back to html2canvas`);
448
+ nativeErrorMsg = nativeError instanceof Error ? nativeError.message : String(nativeError);
449
+ driverLogger.error(`Native screenshot failed: ${nativeErrorMsg}, falling back to html2canvas`);
355
450
  }
356
451
  // Fallback 1: Use html2canvas library for high-quality DOM rendering
357
452
  // Try to use the script manager to register html2canvas for persistence
@@ -422,6 +517,12 @@ export async function captureScreenshot(options = {}) {
422
517
  throw new Error(`html2canvas returned invalid result: ${result?.substring(0, 100) || 'null'}`);
423
518
  }
424
519
  catch (html2canvasError) {
520
+ const html2canvasMsg = html2canvasError instanceof Error ? html2canvasError.message : 'html2canvas failed';
521
+ if (!allowScreenCapture) {
522
+ throw new Error(`Screenshot capture failed. Native API error: ${nativeErrorMsg}, ` +
523
+ `html2canvas error: ${html2canvasMsg}. ` +
524
+ 'Screen Capture API fallback is disabled; set allowScreenCapture to true to request interactive permission.');
525
+ }
425
526
  try {
426
527
  // Fallback to Screen Capture API
427
528
  const result = await executeAsyncInWebview(screenCaptureScript, windowId, 5000, appIdentifier);
@@ -433,9 +534,8 @@ export async function captureScreenshot(options = {}) {
433
534
  }
434
535
  catch (screenCaptureError) {
435
536
  // All methods failed - throw a proper error
436
- const html2canvasMsg = html2canvasError instanceof Error ? html2canvasError.message : 'html2canvas failed';
437
537
  const screenCaptureMsg = screenCaptureError instanceof Error ? screenCaptureError.message : 'Screen Capture API failed';
438
- throw new Error('Screenshot capture failed. Native API not available, ' +
538
+ throw new Error(`Screenshot capture failed. Native API error: ${nativeErrorMsg}, ` +
439
539
  `html2canvas error: ${html2canvasMsg}, ` +
440
540
  `Screen Capture API error: ${screenCaptureMsg}`);
441
541
  }
@@ -454,4 +554,5 @@ export const GetConsoleLogsSchema = z.object({
454
554
  export const CaptureScreenshotSchema = z.object({
455
555
  format: z.enum(['png', 'jpeg']).optional().default('jpeg').describe('Image format'),
456
556
  quality: z.number().min(0).max(100).optional().describe('JPEG quality (0-100)'),
557
+ allowScreenCapture: z.boolean().optional().default(false).describe('Allow the Screen Capture API fallback, which opens an interactive OS permission prompt'),
457
558
  });