@wdio/mcp 3.1.0 → 3.2.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
@@ -353,23 +353,22 @@ All session types support `reporting` labels that appear in the BrowserStack Aut
353
353
 
354
354
  ### Session Management
355
355
 
356
- | Tool | Description |
357
- |---------------------|------------------------------------------------------------------------------------------|
358
- | `start_browser` | Start a browser session (Chrome, Firefox, Edge, Safari; headless/headed, custom dimensions) |
359
- | `start_app_session` | Start an iOS or Android app session via Appium (supports state preservation via noReset) |
360
- | `close_session` | Close or detach from the current browser or app session (supports detach mode) |
361
- | `attach_browser` | Attach to a running Chrome instance via `--remote-debugging-port` (CDP) |
362
- | `emulate_device` | Emulate a mobile/tablet device preset (viewport, DPR, UA, touch); requires BiDi session |
356
+ | Tool | Description |
357
+ |------------------|------------------------------------------------------------------------------------------|
358
+ | `start_session` | Start a browser or app session. Use `platform: 'browser'` for web, `platform: 'ios'`/`'android'` for mobile, or `attach: true` to connect to a running Chrome instance |
359
+ | `launch_chrome` | Launch a new Chrome instance with remote debugging enabled (for use with `start_session({ attach: true })`) |
360
+ | `close_session` | Close or detach from the current session (supports `detach: true` to disconnect without terminating) |
361
+ | `emulate_device` | Emulate a mobile/tablet device preset (viewport, DPR, UA, touch); requires BiDi session |
363
362
 
364
363
  ### Navigation & Page Interaction (Web & Mobile)
365
364
 
366
- | Tool | Description |
367
- |------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
368
- | `navigate` | Navigate to a URL |
369
- | `get_visible_elements` | Get visible, interactable elements on the page. Supports `inViewportOnly` (default: true) to filter viewport elements, and `includeContainers` (default: false) to include layout containers on mobile |
370
- | `get_accessibility` | Get accessibility tree with semantic element information |
371
- | `scroll` | Scroll in a direction (up/down) by specified pixels |
372
- | `take_screenshot` | Capture a screenshot |
365
+ | Tool | Description |
366
+ |-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
367
+ | `navigate` | Navigate to a URL |
368
+ | `get_elements` | Get visible, interactable elements on the page. Supports `inViewportOnly` (default: true) to filter viewport elements, and `includeContainers` (default: false) to include layout containers on mobile |
369
+ | `scroll` | Scroll in a direction (up/down) by specified pixels |
370
+ | `execute_script` | Execute arbitrary JavaScript in the browser context |
371
+ | `switch_tab` | Switch to a different browser tab by index or URL |
373
372
 
374
373
  ### Element Interaction (Web & Mobile)
375
374
 
@@ -382,7 +381,6 @@ All session types support `reporting` labels that appear in the BrowserStack Aut
382
381
 
383
382
  | Tool | Description |
384
383
  |------------------|--------------------------------------------------------|
385
- | `get_cookies` | Get all cookies or a specific cookie by name |
386
384
  | `set_cookie` | Set a cookie with name, value, and optional attributes |
387
385
  | `delete_cookies` | Delete all cookies or a specific cookie |
388
386
 
@@ -394,27 +392,40 @@ All session types support `reporting` labels that appear in the BrowserStack Aut
394
392
  | `swipe` | Swipe in a direction (up/down/left/right) |
395
393
  | `drag_and_drop` | Drag from one location to another |
396
394
 
397
- ### App Lifecycle (iOS/Android)
398
-
399
- | Tool | Description |
400
- |-----------------|--------------------------------------------------------------|
401
- | `get_app_state` | Check app state (installed, running, background, foreground) |
402
-
403
395
  ### Context Switching (Hybrid Apps)
404
396
 
405
- | Tool | Description |
406
- |-----------------------|-------------------------------------------------|
407
- | `get_contexts` | List available contexts (NATIVE_APP, WEBVIEW_*) |
408
- | `get_current_context` | Show the currently active context |
409
- | `switch_context` | Switch between native and webview contexts |
397
+ | Tool | Description |
398
+ |------------------|-------------------------------------------------|
399
+ | `switch_context` | Switch between native and webview contexts |
410
400
 
411
401
  ### Device Control (iOS/Android)
412
402
 
413
- | Tool | Description |
414
- |---------------------------------------|---------------------------------|
415
- | `rotate_device` | Rotate to portrait or landscape |
416
- | `hide_keyboard` | Hide on-screen keyboard |
417
- | `get_geolocation` / `set_geolocation` | Get or set device GPS location |
403
+ | Tool | Description |
404
+ |-------------------|---------------------------------|
405
+ | `rotate_device` | Rotate to portrait or landscape |
406
+ | `hide_keyboard` | Hide on-screen keyboard |
407
+ | `set_geolocation` | Set device GPS location |
408
+
409
+ ### MCP Resources (read-only, no tool call needed)
410
+
411
+ | Resource | Description |
412
+ |-------------------------------------------|--------------------------------------------------------|
413
+ | `wdio://sessions` | Index of all recorded sessions |
414
+ | `wdio://session/current/steps` | Step log for the active session |
415
+ | `wdio://session/current/code` | Generated runnable WebdriverIO JS for the active session |
416
+ | `wdio://session/{id}/steps` | Step log for any past session by ID |
417
+ | `wdio://session/{id}/code` | Generated JS for any past session by ID |
418
+ | `wdio://session/current/elements` | Interactable elements (viewport-only by default) |
419
+ | `wdio://session/current/accessibility` | Accessibility tree |
420
+ | `wdio://session/current/screenshot` | Screenshot (base64) |
421
+ | `wdio://session/current/cookies` | Browser cookies |
422
+ | `wdio://session/current/tabs` | Open browser tabs |
423
+ | `wdio://session/current/contexts` | Native/webview contexts (mobile) |
424
+ | `wdio://session/current/context` | Currently active context (mobile) |
425
+ | `wdio://session/current/app-state` | Mobile app state |
426
+ | `wdio://session/current/geolocation` | Device geolocation |
427
+ | `wdio://session/current/capabilities` | Resolved WebDriver capabilities for the active session |
428
+ | `wdio://browserstack/local-binary` | BrowserStack Local binary download URL and start command |
418
429
 
419
430
  ## Usage Examples
420
431
 
@@ -458,28 +469,26 @@ You are a Testing expert, and want to assess the basic workflows of a web applic
458
469
 
459
470
  ```javascript
460
471
  // Default settings (headed mode, 1280x1080)
461
- start_browser()
472
+ start_session({platform: 'browser'})
462
473
 
463
474
  // Firefox
464
- start_browser({browser: 'firefox'})
475
+ start_session({platform: 'browser', browser: 'firefox'})
465
476
 
466
477
  // Edge
467
- start_browser({browser: 'edge'})
478
+ start_session({platform: 'browser', browser: 'edge'})
468
479
 
469
480
  // Safari (headed only; requires macOS)
470
- start_browser({browser: 'safari'})
481
+ start_session({platform: 'browser', browser: 'safari'})
471
482
 
472
483
  // Headless mode
473
- start_browser({headless: true})
484
+ start_session({platform: 'browser', headless: true})
474
485
 
475
486
  // Custom dimensions
476
- start_browser({windowWidth: 1920, windowHeight: 1080})
477
-
478
- // Headless with custom dimensions
479
- start_browser({headless: true, windowWidth: 1920, windowHeight: 1080})
487
+ start_session({platform: 'browser', windowWidth: 1920, windowHeight: 1080})
480
488
 
481
489
  // Pass custom capabilities (e.g. Chrome extensions, profile, prefs)
482
- start_browser({
490
+ start_session({
491
+ platform: 'browser',
483
492
  headless: false,
484
493
  capabilities: {
485
494
  'goog:chromeOptions': {
@@ -504,9 +513,9 @@ start_browser({
504
513
  // google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug &
505
514
  //
506
515
  // Verify it's ready: curl http://localhost:9222/json/version
507
- attach_browser()
508
- attach_browser({port: 9333})
509
- attach_browser({port: 9222, navigationUrl: 'https://app.example.com'})
516
+ start_session({attach: true})
517
+ start_session({attach: true, port: 9333})
518
+ start_session({attach: true, port: 9222, navigationUrl: 'https://app.example.com'})
510
519
  ```
511
520
 
512
521
  **Device emulation (requires BiDi session):**
@@ -647,8 +656,8 @@ Control app state when creating new sessions using the `noReset` and `fullReset`
647
656
 
648
657
  ```javascript
649
658
  // Preserve login state between test runs
650
- start_app_session({
651
- platform: 'Android',
659
+ start_session({
660
+ platform: 'android',
652
661
  appPath: '/path/to/app.apk',
653
662
  deviceName: 'emulator-5554',
654
663
  noReset: true, // Don't reset app state
@@ -717,10 +726,12 @@ This eliminates the need to manually handle permission popups during automated t
717
726
  Every tool call is automatically recorded to a session history. You can inspect sessions and export runnable code via MCP resources — no extra tool calls needed:
718
727
 
719
728
  - `wdio://sessions` — lists all recorded sessions with type, timestamps, and step count
720
- - `wdio://session/current/steps` — step log for the active session, plus a generated WebdriverIO JS script ready to run with `webdriverio`
721
- - `wdio://session/{sessionId}/steps` — same for any past session by ID
729
+ - `wdio://session/current/steps` — step log for the active session
730
+ - `wdio://session/current/code` — generated runnable WebdriverIO JS for the active session
731
+ - `wdio://session/{sessionId}/steps` — step log for any past session by ID
732
+ - `wdio://session/{sessionId}/code` — generated JS for any past session by ID
722
733
 
723
- The generated script reconstructs the full session — including capabilities, navigation, clicks, and inputs — as a standalone `import { remote } from 'webdriverio'` file.
734
+ The generated script reconstructs the full session — including capabilities, navigation, clicks, and inputs — as a standalone `import { remote } from 'webdriverio'` file. For BrowserStack sessions it includes the full try/catch/finally with automatic session result marking.
724
735
 
725
736
  ## Troubleshooting
726
737
 
package/lib/server.js CHANGED
@@ -46,7 +46,7 @@ var package_default = {
46
46
  type: "git",
47
47
  url: "git://github.com/webdriverio/mcp.git"
48
48
  },
49
- version: "3.0.0",
49
+ version: "3.1.1",
50
50
  description: "MCP server with WebdriverIO for browser and mobile app automation (iOS/Android via Appium)",
51
51
  main: "./lib/server.js",
52
52
  module: "./lib/server.js",
@@ -88,6 +88,7 @@ var package_default = {
88
88
  "@toon-format/toon": "^2.1.0",
89
89
  "@wdio/protocols": "^9.27.0",
90
90
  "@xmldom/xmldom": "^0.8.12",
91
+ "browserstack-local": "^1.5.12",
91
92
  "puppeteer-core": "^24.40.0",
92
93
  sharp: "^0.34.5",
93
94
  webdriverio: "^9.27.0",
@@ -114,7 +115,7 @@ var package_default = {
114
115
  };
115
116
 
116
117
  // src/server.ts
117
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
118
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
118
119
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
119
120
 
120
121
  // src/tools/navigate.tool.ts
@@ -2152,7 +2153,7 @@ var browserstackLocalBinaryResource = {
2152
2153
  };
2153
2154
 
2154
2155
  // src/resources/sessions.resource.ts
2155
- import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
2156
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2156
2157
 
2157
2158
  // src/recording/code-generator.ts
2158
2159
  function escapeStr(value) {
@@ -2176,6 +2177,23 @@ function generateStep(step, history) {
2176
2177
  switch (step.tool) {
2177
2178
  case "start_session": {
2178
2179
  const platform2 = p.platform;
2180
+ const isBrowserStack = "bstack:options" in history.capabilities;
2181
+ const capJson = indentJson(history.capabilities).split("\n").map((line, i) => i > 0 ? ` ${line}` : line).join("\n");
2182
+ if (isBrowserStack) {
2183
+ const nav = platform2 === "browser" && p.navigationUrl ? `
2184
+ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2185
+ return [
2186
+ "const browser = await remote({",
2187
+ " protocol: 'https',",
2188
+ " hostname: 'hub.browserstack.com',",
2189
+ " port: 443,",
2190
+ " path: '/wd/hub',",
2191
+ " user: process.env.BS_USER,",
2192
+ " key: process.env.BS_KEY,",
2193
+ ` capabilities: ${capJson}`,
2194
+ `});${nav}`
2195
+ ].join("\n");
2196
+ }
2179
2197
  if (platform2 === "browser") {
2180
2198
  const nav = p.navigationUrl ? `
2181
2199
  await browser.url('${escapeStr(p.navigationUrl)}');` : "";
@@ -2225,8 +2243,67 @@ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2225
2243
  return `// [unknown tool] ${step.tool}`;
2226
2244
  }
2227
2245
  }
2246
+ function bsStatusUpdateLines(sessionType) {
2247
+ const apiUrl = sessionType === "browser" ? "https://api.browserstack.com/automate/sessions/" : "https://api-cloud.browserstack.com/app-automate/sessions/";
2248
+ return [
2249
+ " const bsAuth = Buffer.from(`${process.env.BS_USER}:${process.env.BS_KEY}`).toString('base64');",
2250
+ ` await fetch('${apiUrl}' + browser.sessionId + '.json', {`,
2251
+ " method: 'PUT',",
2252
+ " headers: { Authorization: 'Basic ' + bsAuth, 'Content-Type': 'application/json' },",
2253
+ " body: JSON.stringify({ status: bsStatus, ...(bsReason ? { reason: bsReason } : {}) })",
2254
+ " });"
2255
+ ];
2256
+ }
2228
2257
  function generateCode(history) {
2258
+ const bstackOptions = history.capabilities["bstack:options"];
2259
+ const isBrowserStack = bstackOptions !== void 0;
2260
+ const usesBrowserstackLocal = bstackOptions?.local === true;
2229
2261
  const steps = history.steps.map((step) => generateStep(step, history)).join("\n").split("\n").map((line) => ` ${line}`).join("\n");
2262
+ if (isBrowserStack) {
2263
+ const bsSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
2264
+ const statusUpdate = bsStatusUpdateLines(history.type).join("\n");
2265
+ const preamble = "let browser;\nlet bsStatus = 'passed';\nlet bsReason;";
2266
+ const catchBlock = "} catch (e) {\n bsStatus = 'failed';\n bsReason = String(e);\n throw e;";
2267
+ const finallyBody = ` if (browser) {
2268
+ ${statusUpdate}
2269
+ await browser.deleteSession();
2270
+ }`;
2271
+ if (usesBrowserstackLocal) {
2272
+ const tunnelSetup = [
2273
+ "",
2274
+ "const tunnel = new BrowserstackTunnel();",
2275
+ "const startTunnel = promisify(tunnel.start.bind(tunnel));",
2276
+ "const stopTunnel = promisify(tunnel.stop.bind(tunnel));",
2277
+ "await startTunnel({ key: process.env.BROWSERSTACK_ACCESS_KEY });",
2278
+ ""
2279
+ ].join("\n");
2280
+ return [
2281
+ "import { remote } from 'webdriverio';",
2282
+ "import { Local as BrowserstackTunnel } from 'browserstack-local';",
2283
+ "import { promisify } from 'node:util';",
2284
+ tunnelSetup,
2285
+ preamble,
2286
+ "try {",
2287
+ bsSteps,
2288
+ catchBlock,
2289
+ "} finally {",
2290
+ finallyBody,
2291
+ " await stopTunnel();",
2292
+ "}"
2293
+ ].join("\n");
2294
+ }
2295
+ return [
2296
+ "import { remote } from 'webdriverio';",
2297
+ "",
2298
+ preamble,
2299
+ "try {",
2300
+ bsSteps,
2301
+ catchBlock,
2302
+ "} finally {",
2303
+ finallyBody,
2304
+ "}"
2305
+ ].join("\n");
2306
+ }
2230
2307
  return `import { remote } from 'webdriverio';
2231
2308
 
2232
2309
  try {
@@ -2786,7 +2863,7 @@ var cookiesResource = {
2786
2863
 
2787
2864
  // src/resources/app-state.resource.ts
2788
2865
  init_state();
2789
- import { ResourceTemplate as ResourceTemplate2 } from "@modelcontextprotocol/sdk/server/mcp";
2866
+ import { ResourceTemplate as ResourceTemplate2 } from "@modelcontextprotocol/sdk/server/mcp.js";
2790
2867
  async function readAppState(bundleId) {
2791
2868
  try {
2792
2869
  const browser = getBrowser();
@@ -2917,60 +2994,6 @@ import { z as z14 } from "zod";
2917
2994
 
2918
2995
  // src/session/lifecycle.ts
2919
2996
  init_state();
2920
- function handleSessionTransition(newSessionId) {
2921
- const state2 = getState();
2922
- if (state2.currentSession && state2.currentSession !== newSessionId) {
2923
- const outgoing = state2.sessionHistory.get(state2.currentSession);
2924
- if (outgoing) {
2925
- outgoing.steps.push({
2926
- index: outgoing.steps.length + 1,
2927
- tool: "__session_transition__",
2928
- params: { newSessionId },
2929
- status: "ok",
2930
- durationMs: 0,
2931
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2932
- });
2933
- outgoing.endedAt = (/* @__PURE__ */ new Date()).toISOString();
2934
- }
2935
- }
2936
- }
2937
- function registerSession(sessionId, browser, metadata, historyEntry) {
2938
- const state2 = getState();
2939
- const oldSessionId = state2.currentSession;
2940
- if (oldSessionId && oldSessionId !== sessionId) {
2941
- handleSessionTransition(sessionId);
2942
- }
2943
- state2.browsers.set(sessionId, browser);
2944
- state2.sessionMetadata.set(sessionId, metadata);
2945
- state2.sessionHistory.set(sessionId, historyEntry);
2946
- state2.currentSession = sessionId;
2947
- if (oldSessionId && oldSessionId !== sessionId) {
2948
- const oldBrowser = state2.browsers.get(oldSessionId);
2949
- if (oldBrowser) {
2950
- oldBrowser.deleteSession().catch(() => {
2951
- });
2952
- state2.browsers.delete(oldSessionId);
2953
- state2.sessionMetadata.delete(oldSessionId);
2954
- }
2955
- }
2956
- }
2957
- async function closeSession(sessionId, detach, isAttached, force) {
2958
- const state2 = getState();
2959
- const browser = state2.browsers.get(sessionId);
2960
- if (!browser) return;
2961
- const history = state2.sessionHistory.get(sessionId);
2962
- if (history) {
2963
- history.endedAt = (/* @__PURE__ */ new Date()).toISOString();
2964
- }
2965
- if (force || !detach && !isAttached) {
2966
- await browser.deleteSession();
2967
- }
2968
- state2.browsers.delete(sessionId);
2969
- state2.sessionMetadata.delete(sessionId);
2970
- if (state2.currentSession === sessionId) {
2971
- state2.currentSession = null;
2972
- }
2973
- }
2974
2997
 
2975
2998
  // src/providers/local-browser.provider.ts
2976
2999
  var LocalBrowserProvider = class {
@@ -3220,6 +3243,8 @@ var LocalAppiumProvider = class {
3220
3243
  var localAppiumProvider = new LocalAppiumProvider();
3221
3244
 
3222
3245
  // src/providers/cloud/browserstack.provider.ts
3246
+ import { promisify } from "util";
3247
+ import { Local as BrowserstackTunnel } from "browserstack-local";
3223
3248
  var BrowserStackProvider = class {
3224
3249
  name = "browserstack";
3225
3250
  getConnectionConfig(_options) {
@@ -3286,6 +3311,40 @@ var BrowserStackProvider = class {
3286
3311
  shouldAutoDetach(_options) {
3287
3312
  return false;
3288
3313
  }
3314
+ async startTunnel(_options) {
3315
+ const key = process.env.BROWSERSTACK_ACCESS_KEY ?? "";
3316
+ const tunnel = new BrowserstackTunnel();
3317
+ const start = promisify(tunnel.start.bind(tunnel));
3318
+ try {
3319
+ await start({ key });
3320
+ } catch (e) {
3321
+ const msg = e instanceof Error ? e.message : String(e);
3322
+ if (msg.includes("another browserstack local client is running") || msg.includes("server is listening on port")) {
3323
+ console.error("[BrowserStack] Tunnel already running \u2014 reusing existing tunnel");
3324
+ return null;
3325
+ }
3326
+ throw e;
3327
+ }
3328
+ return tunnel;
3329
+ }
3330
+ async onSessionClose(sessionId, sessionType, result, tunnelHandle) {
3331
+ if (tunnelHandle) {
3332
+ const tunnel = tunnelHandle;
3333
+ const stop = promisify(tunnel.stop.bind(tunnel));
3334
+ await stop();
3335
+ }
3336
+ const user = process.env.BROWSERSTACK_USERNAME;
3337
+ const key = process.env.BROWSERSTACK_ACCESS_KEY;
3338
+ if (!user || !key) return;
3339
+ const baseUrl = sessionType === "browser" ? "https://api.browserstack.com/automate/sessions" : "https://api-cloud.browserstack.com/app-automate/sessions";
3340
+ const auth = Buffer.from(`${user}:${key}`).toString("base64");
3341
+ const body = { status: result.status, ...result.reason ? { reason: result.reason } : {} };
3342
+ await fetch(`${baseUrl}/${sessionId}.json`, {
3343
+ method: "PUT",
3344
+ headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
3345
+ body: JSON.stringify(body)
3346
+ });
3347
+ }
3289
3348
  };
3290
3349
  var browserStackProvider = new BrowserStackProvider();
3291
3350
 
@@ -3295,6 +3354,84 @@ function getProvider(providerName, platform2) {
3295
3354
  return platform2 === "browser" ? localBrowserProvider : localAppiumProvider;
3296
3355
  }
3297
3356
 
3357
+ // src/session/lifecycle.ts
3358
+ function getSessionResult(history) {
3359
+ const errorStep = history?.steps.find((s) => s.status === "error");
3360
+ return errorStep ? { status: "failed", reason: errorStep.error } : { status: "passed" };
3361
+ }
3362
+ function handleSessionTransition(newSessionId) {
3363
+ const state2 = getState();
3364
+ if (state2.currentSession && state2.currentSession !== newSessionId) {
3365
+ const outgoing = state2.sessionHistory.get(state2.currentSession);
3366
+ if (outgoing) {
3367
+ outgoing.steps.push({
3368
+ index: outgoing.steps.length + 1,
3369
+ tool: "__session_transition__",
3370
+ params: { newSessionId },
3371
+ status: "ok",
3372
+ durationMs: 0,
3373
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3374
+ });
3375
+ outgoing.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3376
+ }
3377
+ }
3378
+ }
3379
+ function registerSession(sessionId, browser, metadata, historyEntry) {
3380
+ const state2 = getState();
3381
+ const oldSessionId = state2.currentSession;
3382
+ if (oldSessionId && oldSessionId !== sessionId) {
3383
+ handleSessionTransition(sessionId);
3384
+ }
3385
+ state2.browsers.set(sessionId, browser);
3386
+ state2.sessionMetadata.set(sessionId, metadata);
3387
+ state2.sessionHistory.set(sessionId, historyEntry);
3388
+ state2.currentSession = sessionId;
3389
+ if (oldSessionId && oldSessionId !== sessionId) {
3390
+ const oldBrowser = state2.browsers.get(oldSessionId);
3391
+ const oldMetadata = state2.sessionMetadata.get(oldSessionId);
3392
+ if (oldBrowser) {
3393
+ void (async () => {
3394
+ if (oldMetadata?.provider) {
3395
+ const oldHistory = state2.sessionHistory.get(oldSessionId);
3396
+ const provider = getProvider(oldMetadata.provider, oldMetadata.type);
3397
+ await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {
3398
+ });
3399
+ }
3400
+ await oldBrowser.deleteSession().catch(() => {
3401
+ });
3402
+ })();
3403
+ state2.browsers.delete(oldSessionId);
3404
+ state2.sessionMetadata.delete(oldSessionId);
3405
+ }
3406
+ }
3407
+ }
3408
+ async function closeSession(sessionId, detach, isAttached, force) {
3409
+ const state2 = getState();
3410
+ const browser = state2.browsers.get(sessionId);
3411
+ if (!browser) return;
3412
+ const history = state2.sessionHistory.get(sessionId);
3413
+ if (history) {
3414
+ history.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3415
+ }
3416
+ const metadata = state2.sessionMetadata.get(sessionId);
3417
+ if (force || !detach && !isAttached) {
3418
+ if (metadata?.provider) {
3419
+ try {
3420
+ const provider = getProvider(metadata.provider, metadata.type);
3421
+ await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
3422
+ } catch (e) {
3423
+ console.error("[WARN] Failed to run provider onSessionClose:", e);
3424
+ }
3425
+ }
3426
+ await browser.deleteSession();
3427
+ }
3428
+ state2.browsers.delete(sessionId);
3429
+ state2.sessionMetadata.delete(sessionId);
3430
+ if (state2.currentSession === sessionId) {
3431
+ state2.currentSession = null;
3432
+ }
3433
+ }
3434
+
3298
3435
  // src/tools/session.tool.ts
3299
3436
  var platformEnum = z14.enum(["browser", "ios", "android"]);
3300
3437
  var browserEnum = z14.enum(["chrome", "firefox", "edge", "safari"]);
@@ -3340,7 +3477,7 @@ var startSessionToolDefinition = {
3340
3477
  port: z14.number().optional(),
3341
3478
  path: z14.string().optional()
3342
3479
  }).optional().describe("Appium server connection (local provider only)"),
3343
- browserstackLocal: coerceBoolean.optional().default(false).describe("Enable BrowserStack Local tunnel for testing against local/internal URLs (BrowserStack only, default: false). IMPORTANT: The BrowserStack Local binary daemon MUST already be running before calling start_session, otherwise all navigation to local/internal URLs will fail with ERR_TUNNEL_CONNECTION_FAILED. Read the wdio://browserstack/local-binary resource for the platform-specific download URL and the exact daemon start command. Do not set this to true without first confirming the daemon is running."),
3480
+ browserstackLocal: coerceBoolean.optional().default(false).describe("Enable BrowserStack Local tunnel for testing against local/internal URLs (BrowserStack only, default: false). When true, the tunnel is started automatically before the session and stopped when the session is closed."),
3344
3481
  navigationUrl: z14.string().optional().describe("URL to navigate to after starting"),
3345
3482
  capabilities: z14.record(z14.string(), z14.unknown()).optional().describe("Additional capabilities to merge")
3346
3483
  }
@@ -3426,12 +3563,15 @@ async function startBrowserSession(args) {
3426
3563
  windowHeight,
3427
3564
  capabilities: userCapabilities
3428
3565
  });
3566
+ const tunnelHandle = args.browserstackLocal ? await provider.startTunnel?.(args) : void 0;
3429
3567
  const wdioBrowser = await remote({ ...connectionConfig, capabilities: mergedCapabilities });
3430
3568
  const { sessionId } = wdioBrowser;
3431
3569
  const sessionMetadata = {
3432
3570
  type: "browser",
3433
3571
  capabilities: mergedCapabilities,
3434
- isAttached: false
3572
+ isAttached: false,
3573
+ provider: args.provider ?? "local",
3574
+ tunnelHandle
3435
3575
  };
3436
3576
  registerSession(sessionId, wdioBrowser, sessionMetadata, {
3437
3577
  sessionId,
@@ -3473,6 +3613,7 @@ async function startMobileSession(args) {
3473
3613
  const provider = getProvider(args.provider ?? "local", args.platform);
3474
3614
  const serverConfig = provider.getConnectionConfig(args);
3475
3615
  const mergedCapabilities = provider.buildCapabilities(args);
3616
+ const tunnelHandle = args.browserstackLocal ? await provider.startTunnel?.(args) : void 0;
3476
3617
  const browser = await remote({ ...serverConfig, capabilities: mergedCapabilities });
3477
3618
  const { sessionId } = browser;
3478
3619
  const shouldAutoDetach = provider.shouldAutoDetach(args);
@@ -3480,7 +3621,9 @@ async function startMobileSession(args) {
3480
3621
  const metadata = {
3481
3622
  type: sessionType,
3482
3623
  capabilities: mergedCapabilities,
3483
- isAttached: shouldAutoDetach
3624
+ isAttached: shouldAutoDetach,
3625
+ provider: args.provider ?? "local",
3626
+ tunnelHandle
3484
3627
  };
3485
3628
  registerSession(sessionId, browser, metadata, {
3486
3629
  sessionId,
@@ -3526,7 +3669,8 @@ async function attachBrowserSession(args) {
3526
3669
  const sessionMetadata = {
3527
3670
  type: "browser",
3528
3671
  capabilities,
3529
- isAttached: true
3672
+ isAttached: true,
3673
+ provider: "local"
3530
3674
  };
3531
3675
  registerSession(sessionId, browser, sessionMetadata, {
3532
3676
  sessionId,