@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.
@@ -17,6 +17,8 @@ export interface SessionInfo {
17
17
  port: number;
18
18
  client?: PluginClient;
19
19
  connected: boolean;
20
+ cwd: string | null;
21
+ pluginVersion: string | null;
20
22
  }
21
23
  /**
22
24
  * Manages discovery and connection to multiple Tauri app instances
@@ -40,8 +42,8 @@ export declare class AppDiscovery {
40
42
  */
41
43
  discoverApps(): Promise<AppInstance[]>;
42
44
  /**
43
- * Connects to a specific app on a host and port
44
- */
45
+ * Connects to a specific app on a host and port
46
+ */
45
47
  connectToPort(port: number, appName?: string, host?: string): Promise<SessionInfo>;
46
48
  /**
47
49
  * Gets the first available app
@@ -45,8 +45,8 @@ export class AppDiscovery {
45
45
  return apps;
46
46
  }
47
47
  /**
48
- * Connects to a specific app on a host and port
49
- */
48
+ * Connects to a specific app on a host and port
49
+ */
50
50
  async connectToPort(port, appName, host) {
51
51
  const targetHost = host ?? this._host;
52
52
  const sessionId = `${targetHost}_${port}`;
@@ -58,13 +58,33 @@ export class AppDiscovery {
58
58
  const client = new PluginClient(targetHost, port);
59
59
  try {
60
60
  await client.connect();
61
+ // Get backend state to retrieve app info and plugin version
62
+ const backendState = await client.sendCommand({
63
+ command: 'get_backend_state',
64
+ args: {},
65
+ });
66
+ let name = appName || `Tauri App (${targetHost}:${port})`, cwd = null, pluginVersion = null;
67
+ if (backendState.success && backendState.data) {
68
+ const data = backendState.data;
69
+ if (data.app?.name) {
70
+ name = data.app.name;
71
+ }
72
+ if (data.app?.identifier) {
73
+ // Use identifier if name not available
74
+ name = data.app.name || data.app.identifier;
75
+ }
76
+ cwd = data.cwd ?? null;
77
+ pluginVersion = data.bridge?.pluginVersion ?? null;
78
+ }
61
79
  const session = {
62
80
  appId: sessionId,
63
- name: appName || `Tauri App (${targetHost}:${port})`,
81
+ name,
64
82
  host: targetHost,
65
83
  port,
66
84
  client,
67
85
  connected: true,
86
+ cwd,
87
+ pluginVersion,
68
88
  };
69
89
  this._activeSessions.set(sessionId, session);
70
90
  return session;
@@ -15,6 +15,12 @@ export interface PluginResponse {
15
15
  warning?: string;
16
16
  };
17
17
  }
18
+ export interface PluginClientOptions {
19
+ /** How often to ping an idle bridge connection. */
20
+ readonly heartbeatIntervalMs?: number;
21
+ /** How long to wait for a heartbeat pong before terminating the socket. */
22
+ readonly heartbeatTimeoutMs?: number;
23
+ }
18
24
  /**
19
25
  * Client to communicate with the MCP Bridge plugin's WebSocket server
20
26
  */
@@ -26,13 +32,19 @@ export declare class PluginClient extends EventEmitter {
26
32
  private _reconnectAttempts;
27
33
  private _shouldReconnect;
28
34
  private _reconnectDelay;
35
+ private _reconnectTimer;
36
+ private _heartbeatTimer;
37
+ private _heartbeatTimeout;
38
+ private readonly _heartbeatIntervalMs;
39
+ private readonly _heartbeatTimeoutMs;
29
40
  private _pendingRequests;
30
41
  /**
31
42
  * Constructor for PluginClient
32
43
  * @param host Host address of the WebSocket server
33
44
  * @param port Port number of the WebSocket server
45
+ * @param options Optional connection liveness settings
34
46
  */
35
- constructor(host: string, port: number);
47
+ constructor(host: string, port: number, options?: PluginClientOptions);
36
48
  /**
37
49
  * Creates a PluginClient with default configuration from environment.
38
50
  */
@@ -53,6 +65,13 @@ export declare class PluginClient extends EventEmitter {
53
65
  * Disconnect from the plugin
54
66
  */
55
67
  disconnect(): void;
68
+ /**
69
+ * Probe the current WebSocket with a ping/pong exchange.
70
+ *
71
+ * `readyState === OPEN` is insufficient for half-open TCP connections, so
72
+ * session reuse calls this before trusting a cached client.
73
+ */
74
+ checkConnection(timeoutMs?: number): Promise<boolean>;
56
75
  /**
57
76
  * Send a command to the plugin and wait for response.
58
77
  *
@@ -64,6 +83,9 @@ export declare class PluginClient extends EventEmitter {
64
83
  * Check if connected
65
84
  */
66
85
  isConnected(): boolean;
86
+ private _clearHeartbeatTimeout;
87
+ private _stopHeartbeat;
88
+ private _startHeartbeat;
67
89
  }
68
90
  /**
69
91
  * Gets the existing singleton PluginClient without creating or modifying it.
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
2
  import { EventEmitter } from 'events';
3
3
  import { buildWebSocketURL, getDefaultHost, getDefaultPort } from '../config.js';
4
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000, DEFAULT_HEARTBEAT_TIMEOUT_MS = 10000, DEFAULT_CONNECTION_CHECK_TIMEOUT_MS = 1000;
4
5
  /**
5
6
  * Client to communicate with the MCP Bridge plugin's WebSocket server
6
7
  */
@@ -13,17 +14,25 @@ export class PluginClient extends EventEmitter {
13
14
  _reconnectAttempts = 0;
14
15
  _shouldReconnect = true; // Keep trying forever until explicitly disconnected
15
16
  _reconnectDelay = 1000; // Start with 1s, max 30s
17
+ _reconnectTimer = null;
18
+ _heartbeatTimer = null;
19
+ _heartbeatTimeout = null;
20
+ _heartbeatIntervalMs;
21
+ _heartbeatTimeoutMs;
16
22
  _pendingRequests = new Map();
17
23
  /**
18
24
  * Constructor for PluginClient
19
25
  * @param host Host address of the WebSocket server
20
26
  * @param port Port number of the WebSocket server
27
+ * @param options Optional connection liveness settings
21
28
  */
22
- constructor(host, port) {
29
+ constructor(host, port, options = {}) {
23
30
  super();
24
31
  this._host = host;
25
32
  this._port = port;
26
33
  this._url = buildWebSocketURL(host, port);
34
+ this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
35
+ this._heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
27
36
  // CRITICAL: Attach a default error handler to prevent crashes.
28
37
  // In Node.js, if an EventEmitter emits 'error' with no listeners, it throws
29
38
  // an uncaught exception that crashes the process. This is especially important
@@ -63,9 +72,13 @@ export class PluginClient extends EventEmitter {
63
72
  this._ws.on('open', () => {
64
73
  // Connected to MCP Bridge plugin
65
74
  this._reconnectAttempts = 0;
75
+ this._startHeartbeat();
66
76
  this.emit('connected');
67
77
  resolve();
68
78
  });
79
+ this._ws.on('pong', () => {
80
+ this._clearHeartbeatTimeout();
81
+ });
69
82
  this._ws.on('message', (data) => {
70
83
  try {
71
84
  const message = JSON.parse(data.toString());
@@ -95,6 +108,7 @@ export class PluginClient extends EventEmitter {
95
108
  });
96
109
  this._ws.on('close', () => {
97
110
  // Disconnected from MCP Bridge plugin
111
+ this._stopHeartbeat();
98
112
  this.emit('disconnected');
99
113
  this._ws = null;
100
114
  // Reject all pending requests since the connection is gone
@@ -107,11 +121,16 @@ export class PluginClient extends EventEmitter {
107
121
  if (this._shouldReconnect) {
108
122
  this._reconnectAttempts++;
109
123
  const delay = Math.min(this._reconnectDelay * this._reconnectAttempts, 30000);
110
- setTimeout(() => {
124
+ this._reconnectTimer = setTimeout(() => {
125
+ this._reconnectTimer = null;
126
+ if (!this._shouldReconnect) {
127
+ return;
128
+ }
111
129
  this.connect().catch(() => {
112
130
  // Reconnection failed - will retry on next close event
113
131
  });
114
132
  }, delay);
133
+ this._reconnectTimer.unref();
115
134
  }
116
135
  });
117
136
  });
@@ -121,11 +140,68 @@ export class PluginClient extends EventEmitter {
121
140
  */
122
141
  disconnect() {
123
142
  this._shouldReconnect = false; // Prevent auto-reconnect
143
+ this._stopHeartbeat();
144
+ if (this._reconnectTimer) {
145
+ clearTimeout(this._reconnectTimer);
146
+ this._reconnectTimer = null;
147
+ }
124
148
  if (this._ws) {
125
149
  this._ws.close();
126
150
  this._ws = null;
127
151
  }
128
152
  }
153
+ /**
154
+ * Probe the current WebSocket with a ping/pong exchange.
155
+ *
156
+ * `readyState === OPEN` is insufficient for half-open TCP connections, so
157
+ * session reuse calls this before trusting a cached client.
158
+ */
159
+ async checkConnection(timeoutMs = DEFAULT_CONNECTION_CHECK_TIMEOUT_MS) {
160
+ const socket = this._ws;
161
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
162
+ return false;
163
+ }
164
+ const openSocket = socket;
165
+ return new Promise((resolve) => {
166
+ const probeState = {
167
+ settled: false,
168
+ timeout: null,
169
+ };
170
+ function finish(isAlive) {
171
+ if (probeState.settled) {
172
+ return;
173
+ }
174
+ probeState.settled = true;
175
+ if (probeState.timeout) {
176
+ clearTimeout(probeState.timeout);
177
+ }
178
+ openSocket.off('pong', handlePong);
179
+ openSocket.off('close', handleDisconnect);
180
+ openSocket.off('error', handleDisconnect);
181
+ resolve(isAlive);
182
+ }
183
+ function handlePong() {
184
+ finish(true);
185
+ }
186
+ function handleDisconnect() {
187
+ finish(false);
188
+ }
189
+ probeState.timeout = setTimeout(() => { finish(false); }, timeoutMs);
190
+ openSocket.once('pong', handlePong);
191
+ openSocket.once('close', handleDisconnect);
192
+ openSocket.once('error', handleDisconnect);
193
+ try {
194
+ openSocket.ping(undefined, undefined, (error) => {
195
+ if (error) {
196
+ finish(false);
197
+ }
198
+ });
199
+ }
200
+ catch {
201
+ finish(false);
202
+ }
203
+ });
204
+ }
129
205
  /**
130
206
  * Send a command to the plugin and wait for response.
131
207
  *
@@ -186,6 +262,55 @@ export class PluginClient extends EventEmitter {
186
262
  isConnected() {
187
263
  return this._ws?.readyState === WebSocket.OPEN;
188
264
  }
265
+ _clearHeartbeatTimeout() {
266
+ if (this._heartbeatTimeout) {
267
+ clearTimeout(this._heartbeatTimeout);
268
+ this._heartbeatTimeout = null;
269
+ }
270
+ }
271
+ _stopHeartbeat() {
272
+ if (this._heartbeatTimer) {
273
+ clearInterval(this._heartbeatTimer);
274
+ this._heartbeatTimer = null;
275
+ }
276
+ this._clearHeartbeatTimeout();
277
+ }
278
+ _startHeartbeat() {
279
+ this._stopHeartbeat();
280
+ this._heartbeatTimer = setInterval(() => {
281
+ const socket = this._ws;
282
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
283
+ return;
284
+ }
285
+ // A previous ping is still unanswered. Terminate instead of letting an
286
+ // OPEN-but-half-dead socket stay in the session cache indefinitely.
287
+ if (this._heartbeatTimeout) {
288
+ socket.terminate();
289
+ return;
290
+ }
291
+ this._heartbeatTimeout = setTimeout(() => {
292
+ this._heartbeatTimeout = null;
293
+ if (this._ws === socket && socket.readyState === WebSocket.OPEN) {
294
+ socket.terminate();
295
+ }
296
+ }, this._heartbeatTimeoutMs);
297
+ this._heartbeatTimeout.unref();
298
+ try {
299
+ socket.ping(undefined, undefined, (error) => {
300
+ if (error && this._ws === socket && socket.readyState === WebSocket.OPEN) {
301
+ socket.terminate();
302
+ }
303
+ });
304
+ }
305
+ catch {
306
+ this._clearHeartbeatTimeout();
307
+ if (this._ws === socket && socket.readyState === WebSocket.OPEN) {
308
+ socket.terminate();
309
+ }
310
+ }
311
+ }, this._heartbeatIntervalMs);
312
+ this._heartbeatTimer.unref();
313
+ }
189
314
  }
190
315
  // Singleton instance
191
316
  let pluginClient = null;
@@ -2,19 +2,23 @@ import { z } from 'zod';
2
2
  export declare const ExecuteIPCCommandSchema: z.ZodObject<{
3
3
  command: z.ZodString;
4
4
  args: z.ZodOptional<z.ZodUnknown>;
5
+ windowId: z.ZodOptional<z.ZodString>;
5
6
  appIdentifier: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
6
7
  }, "strip", z.ZodTypeAny, {
7
8
  command: string;
9
+ windowId?: string | undefined;
8
10
  appIdentifier?: string | number | undefined;
9
11
  args?: unknown;
10
12
  }, {
11
13
  command: string;
14
+ windowId?: string | undefined;
12
15
  appIdentifier?: string | number | undefined;
13
16
  args?: unknown;
14
17
  }>;
15
18
  export declare function executeIPCCommand(options: {
16
19
  command: string;
17
20
  args?: unknown;
21
+ windowId?: string;
18
22
  appIdentifier?: string | number;
19
23
  }): Promise<string>;
20
24
  export declare const ManageIPCMonitoringSchema: z.ZodObject<{
@@ -121,21 +125,21 @@ export declare function resizeWindow(options: {
121
125
  appIdentifier?: string | number;
122
126
  }): Promise<string>;
123
127
  export declare const ManageWindowSchema: z.ZodObject<{
124
- action: z.ZodEnum<["list", "info", "resize"]>;
128
+ action: z.ZodEnum<["list", "info", "resize", "focus", "minimize", "maximize"]>;
125
129
  windowId: z.ZodOptional<z.ZodString>;
126
130
  width: z.ZodOptional<z.ZodNumber>;
127
131
  height: z.ZodOptional<z.ZodNumber>;
128
132
  logical: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
129
133
  appIdentifier: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
130
134
  }, "strip", z.ZodTypeAny, {
131
- action: "list" | "info" | "resize";
135
+ action: "list" | "focus" | "info" | "resize" | "minimize" | "maximize";
132
136
  logical: boolean;
133
137
  windowId?: string | undefined;
134
138
  appIdentifier?: string | number | undefined;
135
139
  width?: number | undefined;
136
140
  height?: number | undefined;
137
141
  }, {
138
- action: "list" | "info" | "resize";
142
+ action: "list" | "focus" | "info" | "resize" | "minimize" | "maximize";
139
143
  windowId?: string | undefined;
140
144
  appIdentifier?: string | number | undefined;
141
145
  width?: number | undefined;
@@ -154,7 +158,7 @@ export declare const ManageWindowSchema: z.ZodObject<{
154
158
  * @returns JSON string with the result
155
159
  */
156
160
  export declare function manageWindow(options: {
157
- action: 'list' | 'info' | 'resize';
161
+ action: 'list' | 'info' | 'resize' | 'focus' | 'minimize' | 'maximize';
158
162
  windowId?: string;
159
163
  width?: number;
160
164
  height?: number;
@@ -1,24 +1,54 @@
1
1
  import { z } from 'zod';
2
2
  import { ensureSessionAndConnect, getExistingPluginClient } from './plugin-client.js';
3
+ import { executeInWebview } from './webview-executor.js';
4
+ const BRIDGE_COMMAND_PREFIX = 'plugin:mcp-bridge|';
3
5
  export const ExecuteIPCCommandSchema = z.object({
4
6
  command: z.string(),
5
7
  args: z.unknown().optional(),
8
+ windowId: z.string().optional().describe('Window label to target (defaults to "main") for app commands'),
6
9
  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.'),
7
10
  });
11
+ async function invokeBridgeCommand(command, args, appIdentifier) {
12
+ const client = await ensureSessionAndConnect(appIdentifier);
13
+ // Send IPC command via WebSocket to the mcp-bridge plugin
14
+ const response = await client.sendCommand({
15
+ command: 'invoke_tauri',
16
+ args: { command, args },
17
+ });
18
+ if (!response.success) {
19
+ return JSON.stringify({ success: false, error: response.error || 'Unknown error' });
20
+ }
21
+ return JSON.stringify({ success: true, result: response.data });
22
+ }
23
+ async function invokeAppCommand(command, args, windowId, appIdentifier) {
24
+ const script = `
25
+ return (async () => {
26
+ const internals = window.__TAURI_INTERNALS__;
27
+
28
+ if (!internals || typeof internals.invoke !== 'function') {
29
+ throw new Error('Tauri IPC is not available in this webview');
30
+ }
31
+ return await internals.invoke(${JSON.stringify(command)}, ${JSON.stringify(args ?? {})});
32
+ })();
33
+ `;
34
+ const result = await executeInWebview(script, windowId, appIdentifier);
35
+ const safeParse = (str) => {
36
+ try {
37
+ return JSON.parse(str);
38
+ }
39
+ catch {
40
+ return str;
41
+ }
42
+ };
43
+ return JSON.stringify({ success: true, result: safeParse(result) });
44
+ }
8
45
  export async function executeIPCCommand(options) {
46
+ const { command, args = {}, windowId, appIdentifier } = options;
9
47
  try {
10
- const { command, args = {}, appIdentifier } = options;
11
- // Ensure we have an active session and are connected
12
- const client = await ensureSessionAndConnect(appIdentifier);
13
- // Send IPC command via WebSocket to the mcp-bridge plugin
14
- const response = await client.sendCommand({
15
- command: 'invoke_tauri',
16
- args: { command, args },
17
- });
18
- if (!response.success) {
19
- return JSON.stringify({ success: false, error: response.error || 'Unknown error' });
48
+ if (command.startsWith(BRIDGE_COMMAND_PREFIX)) {
49
+ return await invokeBridgeCommand(command, args, appIdentifier);
20
50
  }
21
- return JSON.stringify({ success: true, result: response.data });
51
+ return await invokeAppCommand(command, args, windowId, appIdentifier);
22
52
  }
23
53
  catch (error) {
24
54
  const message = error instanceof Error ? error.message : String(error);
@@ -271,17 +301,18 @@ export async function resizeWindow(options) {
271
301
  }
272
302
  }
273
303
  export const ManageWindowSchema = z.object({
274
- action: z.enum(['list', 'info', 'resize'])
275
- .describe('Action: "list" all windows, get "info" for one window, or "resize" a window'),
276
- windowId: z.string().optional()
277
- .describe('Window label to target (defaults to "main"). Required for "info", optional for "resize"'),
304
+ action: z.enum(['list', 'info', 'resize', 'focus', 'minimize', 'maximize']).describe('Action: "list" all windows, get "info" for one window, "resize" a window, ' +
305
+ '"focus" a window, "minimize" a window, or "maximize" a window'),
306
+ windowId: z.string().optional().describe('Window label to target (defaults to "main"). Required for "info", ' +
307
+ 'optional for "resize", "focus", "minimize", "maximize"'),
278
308
  width: z.number().int().positive().optional()
279
309
  .describe('Width in pixels (required for "resize" action)'),
280
310
  height: z.number().int().positive().optional()
281
311
  .describe('Height in pixels (required for "resize" action)'),
282
312
  logical: z.boolean().optional().default(true)
283
313
  .describe('Use logical pixels (true, default) or physical pixels (false). Only for "resize"'),
284
- 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.'),
314
+ appIdentifier: z.union([z.string(), z.number()]).optional().describe('App port or bundle ID to target. Defaults to the only connected app ' +
315
+ 'or the default app if multiple are connected.'),
285
316
  });
286
317
  /**
287
318
  * Unified window management function.
@@ -323,6 +354,30 @@ export async function manageWindow(options) {
323
354
  }
324
355
  return resizeWindow({ width, height, windowId, logical, appIdentifier });
325
356
  }
357
+ case 'focus':
358
+ case 'minimize':
359
+ case 'maximize': {
360
+ try {
361
+ const client = await ensureSessionAndConnect(appIdentifier);
362
+ const response = await client.sendCommand({
363
+ command: 'set_window_state',
364
+ args: { action, windowId: windowId ?? 'main' },
365
+ });
366
+ if (!response.success) {
367
+ // Translate unknown command error to version mismatch message
368
+ if (response.error && response.error.includes('Unknown command')) {
369
+ throw new Error(`manage_window action "${action}" needs tauri-plugin-mcp-bridge 0.13.0 or newer. ` +
370
+ 'The connected app is running an older plugin. Update the plugin in Cargo.toml and rebuild the app.');
371
+ }
372
+ throw new Error(response.error || 'Unknown error');
373
+ }
374
+ return JSON.stringify(response.data);
375
+ }
376
+ catch (error) {
377
+ const message = error instanceof Error ? error.message : String(error);
378
+ throw new Error(`Failed to ${action} window: ${message}`);
379
+ }
380
+ }
326
381
  default: {
327
382
  throw new Error(`Unknown action: ${action}`);
328
383
  }
@@ -1,26 +1,139 @@
1
1
  /**
2
- * Find an element using various selector strategies
2
+ * Find elements using various selector strategies and return structured data
3
3
  *
4
4
  * @param {Object} params
5
5
  * @param {string} params.selector - Element selector, ref ID (e.g., "ref=e3"), or text
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
8
+ * @param {number} params.limit - Maximum number of matches to return
9
+ * @param {string[]} params.properties - Computed CSS properties to include
10
+ * @param {boolean} params.visibleOnly - Return only rendered elements
11
+ * @param {boolean} params.includeHtml - Include outerHTML for each match
7
12
  */
8
13
  (function(params) {
9
- const { selector, strategy } = params;
10
-
11
- var element = window.__MCP__.resolveRef(selector, strategy);
12
-
13
- if (element) {
14
- const outerHTML = element.outerHTML;
15
- // Truncate very long HTML to avoid overwhelming output
16
- const truncated = outerHTML.length > 5000
17
- ? outerHTML.substring(0, 5000) + '...'
18
- : outerHTML;
19
- var msg = 'Found element: ' + truncated;
20
- var count = window.__MCP__.countAll(selector, strategy);
21
- if (count > 1) msg += '\n(+' + (count - 1) + ' more match' + (count - 1 === 1 ? '' : 'es') + ')';
22
- return msg;
14
+ const { selector, strategy, nth, limit, properties, visibleOnly, includeHtml } = params;
15
+
16
+ function getAccessibleName(element) {
17
+ try {
18
+ if (window.ariaApi && typeof window.ariaApi.getName === 'function') {
19
+ return window.ariaApi.getName(element) || null;
20
+ }
21
+ } catch (e) {
22
+ // Ignore aria-api errors
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function getAttributes(element) {
28
+ const attrs = {
29
+ id: element.id || null,
30
+ class: element.className || null,
31
+ role: element.getAttribute('role') || null,
32
+ 'aria-label': element.getAttribute('aria-label') || null,
33
+ 'data-testid': element.getAttribute('data-testid') || null,
34
+ href: element.getAttribute('href') || null,
35
+ type: element.getAttribute('type') || null,
36
+ disabled: element.hasAttribute('disabled') ? '' : null,
37
+ };
38
+ // Remove null values
39
+ const result = {};
40
+ for (const key in attrs) {
41
+ if (attrs[key] !== null) {
42
+ result[key] = attrs[key];
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function getStyles(element) {
49
+ if (!properties || properties.length === 0) return null;
50
+ const computed = window.getComputedStyle(element);
51
+ const result = {};
52
+ for (let i = 0; i < properties.length; i++) {
53
+ const prop = properties[i];
54
+ result[prop] = computed.getPropertyValue(prop);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ function isInteractive(element) {
60
+ return element.matches && element.matches(window.__MCP__.INTERACTIVE_SELECTOR);
61
+ }
62
+
63
+ // Get all candidates first
64
+ var allMatches = window.__MCP__.resolveAll(selector, strategy);
65
+
66
+ // Filter by visibility if requested
67
+ var matches = visibleOnly
68
+ ? allMatches.filter(function(el) { return window.__MCP__.isRendered(el); })
69
+ : allMatches;
70
+
71
+ const totalMatches = matches.length;
72
+
73
+ // Apply nth if specified
74
+ if (typeof nth === 'number') {
75
+ if (!matches[nth]) {
76
+ return JSON.stringify({
77
+ selector: selector,
78
+ strategy: strategy,
79
+ totalMatches: totalMatches,
80
+ returned: 0,
81
+ truncated: false,
82
+ matches: [],
83
+ error: 'nth=' + nth + ' is out of range, ' + totalMatches + ' matches for ' + selector
84
+ });
85
+ }
86
+ matches = [matches[nth]];
23
87
  }
24
88
 
25
- return 'Element not found';
89
+ // Apply limit
90
+ const truncated = matches.length > limit;
91
+ if (truncated) {
92
+ matches = matches.slice(0, limit);
93
+ }
94
+
95
+ const returned = matches.length;
96
+
97
+ const results = matches.map(function(element, index) {
98
+ const rect = element.getBoundingClientRect();
99
+ const ref = window.__MCP__.reverseRefs && window.__MCP__.reverseRefs.has(element)
100
+ ? Array.from(window.__MCP__.reverseRefs.entries()).find(([k, v]) => v === element)[0]
101
+ : null;
102
+
103
+ const match = {
104
+ nth: typeof nth === 'number' ? nth : (index + (typeof nth !== 'number' ? 0 : 0)),
105
+ ref: ref,
106
+ tag: element.tagName.toLowerCase(),
107
+ text: (element.textContent || '').trim(),
108
+ accessibleName: getAccessibleName(element),
109
+ attributes: getAttributes(element),
110
+ rect: {
111
+ x: Math.round(rect.left),
112
+ y: Math.round(rect.top),
113
+ width: Math.round(rect.width),
114
+ height: Math.round(rect.height),
115
+ },
116
+ visible: window.__MCP__.isRendered(element),
117
+ interactive: isInteractive(element),
118
+ };
119
+
120
+ const styles = getStyles(element);
121
+ if (styles) match.styles = styles;
122
+
123
+ if (includeHtml) {
124
+ const html = element.outerHTML;
125
+ match.html = html.length > 2000 ? html.substring(0, 2000) + '…' : html;
126
+ }
127
+
128
+ return match;
129
+ });
130
+
131
+ return JSON.stringify({
132
+ selector: selector,
133
+ strategy: strategy,
134
+ totalMatches: totalMatches,
135
+ returned: returned,
136
+ truncated: truncated,
137
+ matches: results,
138
+ });
26
139
  })