@hypothesi/tauri-mcp-server 0.12.0 → 0.13.1-tauri.2.12.0.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.
@@ -4,15 +4,42 @@
4
4
  * @param {Object} params
5
5
  * @param {string} params.selector - CSS selector, XPath, text, or ref ID (e.g., "ref=e3") for element to focus
6
6
  * @param {string} params.strategy - Selector strategy: 'css', 'xpath', or 'text'
7
+ * @param {number|null} params.nth - Zero-based index to pick one of several matching elements
7
8
  */
8
9
  (function(params) {
9
- const { selector, strategy } = params;
10
+ const { selector, strategy, nth } = params;
10
11
 
11
12
  function resolveElement(selectorOrRef) {
12
13
  if (!selectorOrRef) return null;
13
- var el = window.__MCP__.resolveRef(selectorOrRef, strategy);
14
- if (!el) throw new Error('Element not found: ' + selectorOrRef);
15
- return el;
14
+ var matches = window.__MCP__.resolveAll(selectorOrRef, strategy);
15
+
16
+ if (matches.length === 0) throw new Error('Element not found: ' + selectorOrRef);
17
+ if (typeof nth === 'number') {
18
+ if (!matches[nth]) {
19
+ throw new Error('nth=' + nth + ' is out of range, ' + matches.length + ' matches for ' + selectorOrRef);
20
+ }
21
+ return matches[nth];
22
+ }
23
+ if (matches.length > 1 && strategy === 'text') {
24
+ throw new Error(describeAmbiguity(selectorOrRef, matches));
25
+ }
26
+ return matches[0];
27
+ }
28
+
29
+ function describeAmbiguity(selectorOrRef, matches) {
30
+ var lines = ['Ambiguous text selector "' + selectorOrRef + '" matched ' + matches.length + ' visible elements. Pass nth, or use a CSS selector or ref.'];
31
+ for (var i = 0; i < Math.min(matches.length, 5); i++) {
32
+ var el = matches[i];
33
+ var rect = el.getBoundingClientRect();
34
+ var text = (el.textContent || '').trim().substring(0, 50);
35
+ var tag = el.tagName.toLowerCase();
36
+ var className = el.className || '';
37
+ lines.push(' nth=' + i + ' ' + tag + (className ? '.' + className.split(' ')[0] : '') + ' "' + text + '" rect ' + Math.round(rect.left) + ',' + Math.round(rect.top) + ' ' + Math.round(rect.width) + 'x' + Math.round(rect.height));
38
+ }
39
+ if (matches.length > 5) {
40
+ lines.push(' ... and ' + (matches.length - 5) + ' more');
41
+ }
42
+ return lines.join('\n');
16
43
  }
17
44
 
18
45
  const element = resolveElement(selector);
@@ -58,14 +58,32 @@ export function buildScreenshotCaptureScript(format, quality) {
58
58
  return `
59
59
  ${HTML2CANVAS_RESOLVER_SCRIPT}
60
60
 
61
- // Capture the entire document
61
+ // Render the document, but crop the output to the visible viewport. Without
62
+ // explicit bounds html2canvas allocates a canvas for the full scrollable
63
+ // document, which is both inconsistent with the native implementations and
64
+ // can time out on long pages.
62
65
  const element = document.documentElement;
63
66
  if (!element) {
64
67
  throw new Error('document.documentElement is null');
65
68
  }
66
69
 
70
+ const viewportWidth = Math.max(element.clientWidth, window.innerWidth || 0);
71
+ const viewportHeight = Math.max(element.clientHeight, window.innerHeight || 0);
72
+ const viewportX = window.scrollX || window.pageXOffset || 0;
73
+ const viewportY = window.scrollY || window.pageYOffset || 0;
74
+
67
75
  // Capture the webview
68
- const canvas = await html2canvasFn(element, ${HTML2CANVAS_OPTIONS_SCRIPT});
76
+ const canvas = await html2canvasFn(element, {
77
+ ...${HTML2CANVAS_OPTIONS_SCRIPT},
78
+ x: viewportX,
79
+ y: viewportY,
80
+ width: viewportWidth,
81
+ height: viewportHeight,
82
+ windowWidth: viewportWidth,
83
+ windowHeight: viewportHeight,
84
+ scrollX: viewportX,
85
+ scrollY: viewportY,
86
+ });
69
87
  if (!canvas) {
70
88
  throw new Error('html2canvas returned null canvas');
71
89
  }
@@ -8,9 +8,7 @@ export declare const SCRIPTS: {
8
8
  readonly resolveRef: string;
9
9
  readonly interact: string;
10
10
  readonly swipe: string;
11
- readonly keyboard: string;
12
11
  readonly waitFor: string;
13
- readonly getStyles: string;
14
12
  readonly focus: string;
15
13
  readonly findElement: string;
16
14
  readonly domSnapshot: string;
@@ -28,9 +26,9 @@ export declare function getResolveRefSource(): string;
28
26
  */
29
27
  export declare function buildScript(script: string, params: Record<string, unknown>): string;
30
28
  /**
31
- * Build a script for typing text (uses the keyboard script's typeText function)
29
+ * Build a script for typing text
32
30
  */
33
- export declare function buildTypeScript(selector: string, text: string, strategy?: string): string;
31
+ export declare function buildTypeScript(selector: string, text: string, strategy?: string, nth?: number | null): string;
34
32
  /**
35
33
  * Build a script for key events (press, down, up)
36
34
  */
@@ -16,9 +16,7 @@ export const SCRIPTS = {
16
16
  resolveRef: loadScript('resolve-ref'),
17
17
  interact: loadScript('interact'),
18
18
  swipe: loadScript('swipe'),
19
- keyboard: loadScript('keyboard'),
20
19
  waitFor: loadScript('wait-for'),
21
- getStyles: loadScript('get-styles'),
22
20
  focus: loadScript('focus'),
23
21
  findElement: loadScript('find-element'),
24
22
  domSnapshot: loadScript('dom-snapshot'),
@@ -40,21 +38,52 @@ export function buildScript(script, params) {
40
38
  return `(${script})(${JSON.stringify(params)})`;
41
39
  }
42
40
  /**
43
- * Build a script for typing text (uses the keyboard script's typeText function)
41
+ * Build a script for typing text
44
42
  */
45
- export function buildTypeScript(selector, text, strategy) {
46
- const escapedText = text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
47
- const escapedSelector = selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
43
+ export function buildTypeScript(selector, text, strategy, nth) {
48
44
  const strat = strategy || 'css';
45
+ const nthValue = nth ?? null;
49
46
  return `
50
47
  (function() {
51
- const selector = '${escapedSelector}';
52
- const strategy = '${strat}';
53
- const text = '${escapedText}';
48
+ const selector = ${JSON.stringify(selector)};
49
+ const strategy = ${JSON.stringify(strat)};
50
+ const text = ${JSON.stringify(text)};
51
+ const nth = ${JSON.stringify(nthValue)};
54
52
 
55
- var element = window.__MCP__.resolveRef(selector, strategy);
56
- if (!element) throw new Error('Element not found: ' + selector);
53
+ function resolveElement(selectorOrRef) {
54
+ if (!selectorOrRef) return null;
55
+ var matches = window.__MCP__.resolveAll(selectorOrRef, strategy);
57
56
 
57
+ if (matches.length === 0) throw new Error('Element not found: ' + selectorOrRef);
58
+ if (typeof nth === 'number') {
59
+ if (!matches[nth]) {
60
+ throw new Error('nth=' + nth + ' is out of range, ' + matches.length + ' matches for ' + selectorOrRef);
61
+ }
62
+ return matches[nth];
63
+ }
64
+ if (matches.length > 1 && strategy === 'text') {
65
+ throw new Error(describeAmbiguity(selectorOrRef, matches));
66
+ }
67
+ return matches[0];
68
+ }
69
+
70
+ function describeAmbiguity(selectorOrRef, matches) {
71
+ var lines = ['Ambiguous text selector "' + selectorOrRef + '" matched ' + matches.length + ' visible elements. Pass nth, or use a CSS selector or ref.'];
72
+ for (var i = 0; i < Math.min(matches.length, 5); i++) {
73
+ var el = matches[i];
74
+ var rect = el.getBoundingClientRect();
75
+ var text = (el.textContent || '').trim().substring(0, 50);
76
+ var tag = el.tagName.toLowerCase();
77
+ var className = el.className || '';
78
+ lines.push(' nth=' + i + ' ' + tag + (className ? '.' + className.split(' ')[0] : '') + ' "' + text + '" rect ' + Math.round(rect.left) + ',' + Math.round(rect.top) + ' ' + Math.round(rect.width) + 'x' + Math.round(rect.height));
79
+ }
80
+ if (matches.length > 5) {
81
+ lines.push(' ... and ' + (matches.length - 5) + ' more');
82
+ }
83
+ return lines.join('\\n');
84
+ }
85
+
86
+ var element = resolveElement(selector);
58
87
  element.focus();
59
88
 
60
89
  // Use native prototype setter to bypass React's value tracker
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Webview interaction script - handles click, double-click, long-press, and scroll actions
2
+ * Webview interaction script - handles click, double-click, long-press, scroll, swipe, hover, and right-click actions
3
3
  * This script is injected into the webview and executed with parameters.
4
4
  *
5
5
  * @param {Object} params
6
6
  * @param {string} params.action - The action to perform
7
7
  * @param {string|null} params.selector - CSS selector, XPath, text, or ref ID (e.g., "ref=e3") for the element
8
8
  * @param {string} params.strategy - Selector strategy: 'css', 'xpath', or 'text'
9
+ * @param {number|null} params.nth - Zero-based index to pick one of several matching elements
9
10
  * @param {number|null} params.x - X coordinate
10
11
  * @param {number|null} params.y - Y coordinate
11
12
  * @param {number} params.duration - Duration for long-press
@@ -13,13 +14,39 @@
13
14
  * @param {number} params.scrollY - Vertical scroll amount
14
15
  */
15
16
  (function(params) {
16
- const { action, selector, strategy, x, y, duration, scrollX, scrollY } = params;
17
+ const { action, selector, strategy, nth, x, y, duration, scrollX, scrollY } = params;
17
18
 
18
19
  function resolveElement(selectorOrRef) {
19
20
  if (!selectorOrRef) return null;
20
- var el = window.__MCP__.resolveRef(selectorOrRef, strategy);
21
- if (!el) throw new Error('Element not found: ' + selectorOrRef);
22
- return el;
21
+ var matches = window.__MCP__.resolveAll(selectorOrRef, strategy);
22
+
23
+ if (matches.length === 0) throw new Error('Element not found: ' + selectorOrRef);
24
+ if (typeof nth === 'number') {
25
+ if (!matches[nth]) {
26
+ throw new Error('nth=' + nth + ' is out of range, ' + matches.length + ' matches for ' + selectorOrRef);
27
+ }
28
+ return matches[nth];
29
+ }
30
+ if (matches.length > 1 && strategy === 'text') {
31
+ throw new Error(describeAmbiguity(selectorOrRef, matches));
32
+ }
33
+ return matches[0];
34
+ }
35
+
36
+ function describeAmbiguity(selectorOrRef, matches) {
37
+ var lines = ['Ambiguous text selector "' + selectorOrRef + '" matched ' + matches.length + ' visible elements. Pass nth, or use a CSS selector or ref.'];
38
+ for (var i = 0; i < Math.min(matches.length, 5); i++) {
39
+ var el = matches[i];
40
+ var rect = el.getBoundingClientRect();
41
+ var text = (el.textContent || '').trim().substring(0, 50);
42
+ var tag = el.tagName.toLowerCase();
43
+ var className = el.className || '';
44
+ lines.push(' nth=' + i + ' ' + tag + (className ? '.' + className.split(' ')[0] : '') + ' "' + text + '" rect ' + Math.round(rect.left) + ',' + Math.round(rect.top) + ' ' + Math.round(rect.width) + 'x' + Math.round(rect.height));
45
+ }
46
+ if (matches.length > 5) {
47
+ lines.push(' ... and ' + (matches.length - 5) + ' more');
48
+ }
49
+ return lines.join('\n');
23
50
  }
24
51
 
25
52
  function matchHint() {
@@ -62,6 +89,19 @@
62
89
  clientY: targetY,
63
90
  };
64
91
 
92
+ const pointerOptions = {
93
+ ...eventOptions,
94
+ pointerId: 1,
95
+ pointerType: 'mouse',
96
+ isPrimary: true,
97
+ };
98
+
99
+ const rightButtonOptions = {
100
+ ...eventOptions,
101
+ button: 2,
102
+ buttons: 2,
103
+ };
104
+
65
105
  if (action === 'click') {
66
106
  if (element) {
67
107
  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
@@ -94,6 +134,27 @@
94
134
  return `Long-pressed at (${targetX}, ${targetY}) for ${duration}ms` + matchHint();
95
135
  }
96
136
 
137
+ if (action === 'hover') {
138
+ if (element) {
139
+ element.dispatchEvent(new PointerEvent('pointerover', pointerOptions));
140
+ element.dispatchEvent(new PointerEvent('pointerenter', pointerOptions));
141
+ element.dispatchEvent(new MouseEvent('mouseover', eventOptions));
142
+ element.dispatchEvent(new MouseEvent('mouseenter', eventOptions));
143
+ element.dispatchEvent(new PointerEvent('pointermove', pointerOptions));
144
+ element.dispatchEvent(new MouseEvent('mousemove', eventOptions));
145
+ }
146
+ return `Hovered at (${targetX}, ${targetY})` + matchHint();
147
+ }
148
+
149
+ if (action === 'right-click') {
150
+ if (element) {
151
+ element.dispatchEvent(new MouseEvent('mousedown', rightButtonOptions));
152
+ element.dispatchEvent(new MouseEvent('mouseup', rightButtonOptions));
153
+ element.dispatchEvent(new MouseEvent('contextmenu', rightButtonOptions));
154
+ }
155
+ return `Right-clicked at (${targetX}, ${targetY})` + matchHint();
156
+ }
157
+
97
158
  if (action === 'scroll') {
98
159
  const scrollTarget = element || window;
99
160
  if (scrollX !== 0 || scrollY !== 0) {
@@ -15,6 +15,84 @@
15
15
 
16
16
  var REF_PATTERN = /^\[?(?:ref=)?(e\d+)\]?$/;
17
17
 
18
+ var NON_RENDERED_TAGS = {
19
+ SCRIPT: 1, STYLE: 1, TITLE: 1, HEAD: 1, META: 1,
20
+ LINK: 1, TEMPLATE: 1, NOSCRIPT: 1, BASE: 1,
21
+ };
22
+
23
+ var INTERACTIVE_SELECTOR = 'a,button,input,select,textarea,summary,' +
24
+ '[role=button],[role=link],[role=tab],[role=menuitem],[role=option],[tabindex]';
25
+
26
+ function isRendered(element) {
27
+ if (!element || element.nodeType !== 1) return false;
28
+ if (NON_RENDERED_TAGS[element.tagName]) return false;
29
+ if (element.hidden) return false;
30
+ if (element.getClientRects().length === 0) return false;
31
+ try {
32
+ var style = window.getComputedStyle(element);
33
+ if (style.display === 'none') return false;
34
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
35
+ } catch (e) {
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ function hasMatchingDescendant(element, needle) {
42
+ var descendants = element.querySelectorAll('*');
43
+ for (var i = 0; i < descendants.length; i++) {
44
+ var el = descendants[i];
45
+ if (!isRendered(el)) continue;
46
+ var content = (el.textContent || '').trim();
47
+ if (content.indexOf(needle) !== -1) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function preferInteractive(el) {
53
+ if (el.matches(INTERACTIVE_SELECTOR)) return el;
54
+ var host = el.closest(INTERACTIVE_SELECTOR);
55
+ return host && isRendered(host) ? host : el;
56
+ }
57
+
58
+ function dedupe(arr) {
59
+ var seen = new Set();
60
+ var result = [];
61
+ for (var i = 0; i < arr.length; i++) {
62
+ var el = arr[i];
63
+ if (!seen.has(el)) {
64
+ seen.add(el);
65
+ result.push(el);
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+
71
+ function textCandidates(needle) {
72
+ var all = document.body ? document.body.querySelectorAll('*') : [],
73
+ exact = [], partial = [];
74
+
75
+ for (var i = 0; i < all.length; i++) {
76
+ var el = all[i],
77
+ content = (el.textContent || '').trim(),
78
+ attributeText = [
79
+ el.getAttribute('placeholder'),
80
+ el.getAttribute('aria-label'),
81
+ el.getAttribute('title'),
82
+ ].filter(Boolean),
83
+ exactAttribute = attributeText.indexOf(needle) !== -1,
84
+ partialAttribute = attributeText.some(function(value) { return value.indexOf(needle) !== -1; }),
85
+ textMatch = content.indexOf(needle) !== -1;
86
+
87
+ if (!textMatch && !partialAttribute) continue;
88
+ if (!isRendered(el)) continue;
89
+ if (textMatch && el.querySelector('*') && hasMatchingDescendant(el, needle)) continue;
90
+ (content === needle || exactAttribute ? exact : partial).push(preferInteractive(el));
91
+ }
92
+
93
+ return dedupe(exact.length > 0 ? exact : partial);
94
+ }
95
+
18
96
  function xpathForText(text) {
19
97
  // Escape single quotes for XPath by splitting on ' and using concat()
20
98
  if (text.indexOf("'") === -1) {
@@ -47,39 +125,26 @@
47
125
  }
48
126
 
49
127
  if (strategy === 'text') {
50
- // First try: match element text content
51
- var xpath = xpathForText(selectorOrRef);
52
- var result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
53
- if (result.singleNodeValue) return result.singleNodeValue;
54
-
55
- // Fallback: search placeholder, aria-label, and title attributes
56
- var attrSelectors = [
57
- '[placeholder*="' + selectorOrRef.replace(/"/g, '\\"') + '"]',
58
- '[aria-label*="' + selectorOrRef.replace(/"/g, '\\"') + '"]',
59
- '[title*="' + selectorOrRef.replace(/"/g, '\\"') + '"]',
60
- ];
61
- for (var i = 0; i < attrSelectors.length; i++) {
62
- var el = document.querySelector(attrSelectors[i]);
63
- if (el) return el;
64
- }
65
- return null;
128
+ var candidates = textCandidates(selectorOrRef);
129
+ return candidates[0] || null;
66
130
  }
67
131
 
68
132
  if (strategy === 'xpath') {
69
133
  var result = document.evaluate(selectorOrRef, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
70
- return result.singleNodeValue;
134
+ var node = result.singleNodeValue;
135
+ return (node && isRendered(node)) ? node : null;
71
136
  }
72
137
 
73
138
  // Default: CSS selector
74
139
  return document.querySelector(selectorOrRef);
75
140
  };
76
141
 
77
- /**
78
- * Resolve all matching elements as an Array.
79
- * @param {string} selector - Selector, XPath, or text
80
- * @param {string} [strategy] - 'css' (default), 'xpath', or 'text'
81
- * @returns {Element[]}
82
- */
142
+ /**
143
+ * Resolve all matching elements as an Array.
144
+ * @param {string} selector - Selector, XPath, or text
145
+ * @param {string} [strategy] - 'css' (default), 'xpath', or 'text'
146
+ * @returns {Element[]}
147
+ */
83
148
  window.__MCP__.resolveAll = function(selector, strategy) {
84
149
  if (!selector) return [];
85
150
 
@@ -91,33 +156,15 @@
91
156
  }
92
157
 
93
158
  if (strategy === 'text') {
94
- // First try: match element text content
95
- var xpath = xpathForText(selector);
96
- var snapshot = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
97
- var results = [];
98
- for (var i = 0; i < snapshot.snapshotLength; i++) {
99
- results.push(snapshot.snapshotItem(i));
100
- }
101
- if (results.length > 0) return results;
102
-
103
- // Fallback: search placeholder, aria-label, and title attributes
104
- var attrSelectors = [
105
- '[placeholder*="' + selector.replace(/"/g, '\\"') + '"]',
106
- '[aria-label*="' + selector.replace(/"/g, '\\"') + '"]',
107
- '[title*="' + selector.replace(/"/g, '\\"') + '"]',
108
- ];
109
- for (var i = 0; i < attrSelectors.length; i++) {
110
- var found = Array.from(document.querySelectorAll(attrSelectors[i]));
111
- if (found.length > 0) return results.concat(found);
112
- }
113
- return results;
159
+ return textCandidates(selector);
114
160
  }
115
161
 
116
162
  if (strategy === 'xpath') {
117
163
  var snapshot = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
118
164
  var results = [];
119
165
  for (var i = 0; i < snapshot.snapshotLength; i++) {
120
- results.push(snapshot.snapshotItem(i));
166
+ var node = snapshot.snapshotItem(i);
167
+ if (node && isRendered(node)) results.push(node);
121
168
  }
122
169
  return results;
123
170
  }
@@ -126,13 +173,17 @@
126
173
  return Array.from(document.querySelectorAll(selector));
127
174
  };
128
175
 
129
- /**
130
- * Count all matching elements.
131
- * @param {string} selector - Selector, XPath, or text
132
- * @param {string} [strategy] - 'css' (default), 'xpath', or 'text'
133
- * @returns {number}
134
- */
176
+ /**
177
+ * Count all matching elements.
178
+ * @param {string} selector - Selector, XPath, or text
179
+ * @param {string} [strategy] - 'css' (default), 'xpath', or 'text'
180
+ * @returns {number}
181
+ */
135
182
  window.__MCP__.countAll = function(selector, strategy) {
136
183
  return window.__MCP__.resolveAll(selector, strategy).length;
137
184
  };
185
+
186
+ // Export for reuse by other scripts
187
+ window.__MCP__.isRendered = isRendered;
188
+ window.__MCP__.INTERACTIVE_SELECTOR = INTERACTIVE_SELECTOR;
138
189
  })();
@@ -41,6 +41,7 @@ export interface SessionInfo {
41
41
  port: number;
42
42
  client: PluginClient;
43
43
  connected: boolean;
44
+ pluginVersion: string | null;
44
45
  }
45
46
  /**
46
47
  * Check if any session is currently active.
@@ -4,6 +4,7 @@ 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
@@ -211,6 +212,30 @@ function promoteNextDefault() {
211
212
  defaultPort = null;
212
213
  }
213
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
+ }
214
239
  async function handleStatusAction() {
215
240
  if (activeSessions.size === 0) {
216
241
  return JSON.stringify({
@@ -219,6 +244,9 @@ async function handleStatusAction() {
219
244
  identifier: null,
220
245
  host: null,
221
246
  port: null,
247
+ pluginVersion: null,
248
+ serverVersion: PLUGIN_VERSION_FULL,
249
+ versionWarning: describeVersionSkew(null),
222
250
  });
223
251
  }
224
252
  if (activeSessions.size === 1) {
@@ -230,8 +258,12 @@ async function handleStatusAction() {
230
258
  identifier: null,
231
259
  host: null,
232
260
  port: null,
261
+ pluginVersion: null,
262
+ serverVersion: PLUGIN_VERSION_FULL,
263
+ versionWarning: describeVersionSkew(null),
233
264
  });
234
265
  }
266
+ const versionWarning = describeVersionSkew(session.pluginVersion);
235
267
  return JSON.stringify({
236
268
  connected: true,
237
269
  app: session.name,
@@ -239,15 +271,22 @@ async function handleStatusAction() {
239
271
  cwd: session.cwd,
240
272
  host: session.host,
241
273
  port: session.port,
274
+ pluginVersion: session.pluginVersion,
275
+ serverVersion: PLUGIN_VERSION_FULL,
276
+ versionWarning,
242
277
  });
243
278
  }
244
279
  const apps = Array.from(activeSessions.values()).map((session) => {
280
+ const versionWarning = describeVersionSkew(session.pluginVersion);
245
281
  return {
246
282
  name: session.name,
247
283
  identifier: session.identifier,
248
284
  cwd: session.cwd,
249
285
  host: session.host,
250
286
  port: session.port,
287
+ pluginVersion: session.pluginVersion,
288
+ serverVersion: PLUGIN_VERSION_FULL,
289
+ versionWarning,
251
290
  isDefault: session.port === defaultPort,
252
291
  };
253
292
  });
@@ -256,12 +295,13 @@ async function handleStatusAction() {
256
295
  apps,
257
296
  totalCount: apps.length,
258
297
  defaultPort,
298
+ serverVersion: PLUGIN_VERSION_FULL,
259
299
  });
260
300
  }
261
301
  async function handleStartAction(host, port) {
262
302
  const configuredHost = host ?? getDefaultHost();
263
303
  const configuredPort = port ?? getDefaultPort();
264
- if (activeSessions.has(configuredPort)) {
304
+ if (await keepResponsiveSession(configuredPort)) {
265
305
  return `Already connected to app on port ${configuredPort}`;
266
306
  }
267
307
  let connectedSession = null;
@@ -296,12 +336,12 @@ async function handleStartAction(host, port) {
296
336
  if (!connectedSession) {
297
337
  return `Session start failed - no Tauri app found at localhost or ${configuredHost}:${configuredPort}`;
298
338
  }
299
- if (activeSessions.has(connectedSession.port)) {
339
+ if (await keepResponsiveSession(connectedSession.port)) {
300
340
  return `Already connected to app on port ${connectedSession.port}`;
301
341
  }
302
342
  const client = new PluginClient(connectedSession.host, connectedSession.port);
303
343
  await client.connect();
304
- const { identifier, cwd } = await fetchAppMetadata(client);
344
+ const { identifier, cwd, pluginVersion } = await fetchAppMetadata(client);
305
345
  const sessionInfo = {
306
346
  name: connectedSession.name,
307
347
  identifier,
@@ -310,11 +350,17 @@ async function handleStartAction(host, port) {
310
350
  port: connectedSession.port,
311
351
  client,
312
352
  connected: true,
353
+ pluginVersion,
313
354
  };
314
355
  activeSessions.set(connectedSession.port, sessionInfo);
315
356
  defaultPort = connectedSession.port;
316
357
  sessionLogger.info(`Session started: ${sessionInfo.name} (${sessionInfo.host}:${sessionInfo.port}) [DEFAULT]`);
317
- 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;
318
364
  }
319
365
  async function handleStopAction(appIdentifier) {
320
366
  if (appIdentifier !== undefined) {
@@ -376,16 +422,17 @@ async function fetchAppMetadata(client) {
376
422
  args: { command: 'plugin:mcp-bridge|get_backend_state', args: {} },
377
423
  });
378
424
  if (!response.success || !response.data) {
379
- return { identifier: null, cwd: null };
425
+ return { identifier: null, cwd: null, pluginVersion: null };
380
426
  }
381
427
  const state = response.data;
382
428
  return {
383
429
  identifier: state.app?.identifier ?? null,
384
430
  cwd: typeof state.cwd === 'string' && state.cwd.length > 0 ? state.cwd : null,
431
+ pluginVersion: state.bridge?.pluginVersion ?? null,
385
432
  };
386
433
  }
387
434
  catch {
388
- return { identifier: null, cwd: null };
435
+ return { identifier: null, cwd: null, pluginVersion: null };
389
436
  }
390
437
  }
391
438
  /**