@wdio/mcp 3.1.1 → 3.2.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.
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.1.0",
49
+ version: "3.2.0",
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",
@@ -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 {
@@ -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,12 +3243,18 @@ 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 { tmpdir as tmpdir2 } from "os";
3248
+ import { join as join2 } from "path";
3249
+ import { Local as BrowserstackTunnel } from "browserstack-local";
3223
3250
  var BrowserStackProvider = class {
3224
3251
  name = "browserstack";
3225
- getConnectionConfig(_options) {
3252
+ getConnectionConfig(options) {
3253
+ const platform2 = options.platform;
3254
+ const hostname = platform2 === "browser" ? "hub.browserstack.com" : "hub-cloud.browserstack.com";
3226
3255
  return {
3227
3256
  protocol: "https",
3228
- hostname: "hub.browserstack.com",
3257
+ hostname,
3229
3258
  port: 443,
3230
3259
  path: "/wd/hub",
3231
3260
  user: process.env.BROWSERSTACK_USERNAME,
@@ -3286,6 +3315,41 @@ var BrowserStackProvider = class {
3286
3315
  shouldAutoDetach(_options) {
3287
3316
  return false;
3288
3317
  }
3318
+ async startTunnel(_options) {
3319
+ const key = process.env.BROWSERSTACK_ACCESS_KEY ?? "";
3320
+ const tunnel = new BrowserstackTunnel();
3321
+ const start = promisify(tunnel.start.bind(tunnel));
3322
+ try {
3323
+ const logFile = join2(tmpdir2(), "browserstack-local.log");
3324
+ await start({ key, forceLocal: true, logFile });
3325
+ } catch (e) {
3326
+ const msg = (e !== null && typeof e === "object" ? e.message : void 0) ?? String(e);
3327
+ if (msg.includes("another browserstack local client is running") || msg.includes("server is listening on port")) {
3328
+ console.error("[BrowserStack] Tunnel already running \u2014 reusing existing tunnel");
3329
+ return null;
3330
+ }
3331
+ throw e;
3332
+ }
3333
+ return tunnel;
3334
+ }
3335
+ async onSessionClose(sessionId, sessionType, result, tunnelHandle) {
3336
+ if (tunnelHandle) {
3337
+ const tunnel = tunnelHandle;
3338
+ const stop = promisify(tunnel.stop.bind(tunnel));
3339
+ await stop();
3340
+ }
3341
+ const user = process.env.BROWSERSTACK_USERNAME;
3342
+ const key = process.env.BROWSERSTACK_ACCESS_KEY;
3343
+ if (!user || !key) return;
3344
+ const baseUrl = sessionType === "browser" ? "https://api.browserstack.com/automate/sessions" : "https://api-cloud.browserstack.com/app-automate/sessions";
3345
+ const auth = Buffer.from(`${user}:${key}`).toString("base64");
3346
+ const body = { status: result.status, ...result.reason ? { reason: result.reason } : {} };
3347
+ await fetch(`${baseUrl}/${sessionId}.json`, {
3348
+ method: "PUT",
3349
+ headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
3350
+ body: JSON.stringify(body)
3351
+ });
3352
+ }
3289
3353
  };
3290
3354
  var browserStackProvider = new BrowserStackProvider();
3291
3355
 
@@ -3295,6 +3359,84 @@ function getProvider(providerName, platform2) {
3295
3359
  return platform2 === "browser" ? localBrowserProvider : localAppiumProvider;
3296
3360
  }
3297
3361
 
3362
+ // src/session/lifecycle.ts
3363
+ function getSessionResult(history) {
3364
+ const errorStep = history?.steps.find((s) => s.status === "error");
3365
+ return errorStep ? { status: "failed", reason: errorStep.error } : { status: "passed" };
3366
+ }
3367
+ function handleSessionTransition(newSessionId) {
3368
+ const state2 = getState();
3369
+ if (state2.currentSession && state2.currentSession !== newSessionId) {
3370
+ const outgoing = state2.sessionHistory.get(state2.currentSession);
3371
+ if (outgoing) {
3372
+ outgoing.steps.push({
3373
+ index: outgoing.steps.length + 1,
3374
+ tool: "__session_transition__",
3375
+ params: { newSessionId },
3376
+ status: "ok",
3377
+ durationMs: 0,
3378
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3379
+ });
3380
+ outgoing.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3381
+ }
3382
+ }
3383
+ }
3384
+ function registerSession(sessionId, browser, metadata, historyEntry) {
3385
+ const state2 = getState();
3386
+ const oldSessionId = state2.currentSession;
3387
+ if (oldSessionId && oldSessionId !== sessionId) {
3388
+ handleSessionTransition(sessionId);
3389
+ }
3390
+ state2.browsers.set(sessionId, browser);
3391
+ state2.sessionMetadata.set(sessionId, metadata);
3392
+ state2.sessionHistory.set(sessionId, historyEntry);
3393
+ state2.currentSession = sessionId;
3394
+ if (oldSessionId && oldSessionId !== sessionId) {
3395
+ const oldBrowser = state2.browsers.get(oldSessionId);
3396
+ const oldMetadata = state2.sessionMetadata.get(oldSessionId);
3397
+ if (oldBrowser) {
3398
+ void (async () => {
3399
+ if (oldMetadata?.provider) {
3400
+ const oldHistory = state2.sessionHistory.get(oldSessionId);
3401
+ const provider = getProvider(oldMetadata.provider, oldMetadata.type);
3402
+ await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {
3403
+ });
3404
+ }
3405
+ await oldBrowser.deleteSession().catch(() => {
3406
+ });
3407
+ })();
3408
+ state2.browsers.delete(oldSessionId);
3409
+ state2.sessionMetadata.delete(oldSessionId);
3410
+ }
3411
+ }
3412
+ }
3413
+ async function closeSession(sessionId, detach, isAttached, force) {
3414
+ const state2 = getState();
3415
+ const browser = state2.browsers.get(sessionId);
3416
+ if (!browser) return;
3417
+ const history = state2.sessionHistory.get(sessionId);
3418
+ if (history) {
3419
+ history.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3420
+ }
3421
+ const metadata = state2.sessionMetadata.get(sessionId);
3422
+ if (force || !detach && !isAttached) {
3423
+ if (metadata?.provider) {
3424
+ try {
3425
+ const provider = getProvider(metadata.provider, metadata.type);
3426
+ await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
3427
+ } catch (e) {
3428
+ console.error("[WARN] Failed to run provider onSessionClose:", e);
3429
+ }
3430
+ }
3431
+ await browser.deleteSession();
3432
+ }
3433
+ state2.browsers.delete(sessionId);
3434
+ state2.sessionMetadata.delete(sessionId);
3435
+ if (state2.currentSession === sessionId) {
3436
+ state2.currentSession = null;
3437
+ }
3438
+ }
3439
+
3298
3440
  // src/tools/session.tool.ts
3299
3441
  var platformEnum = z14.enum(["browser", "ios", "android"]);
3300
3442
  var browserEnum = z14.enum(["chrome", "firefox", "edge", "safari"]);
@@ -3340,7 +3482,7 @@ var startSessionToolDefinition = {
3340
3482
  port: z14.number().optional(),
3341
3483
  path: z14.string().optional()
3342
3484
  }).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."),
3485
+ browserstackLocal: z14.union([coerceBoolean, z14.literal("external")]).optional().default(false).describe('Enable BrowserStack Local tunnel routing (BrowserStack only, default: false). true = auto-start tunnel before session and stop on close. "external" = tunnel already running externally, set local: true in capabilities only.'),
3344
3486
  navigationUrl: z14.string().optional().describe("URL to navigate to after starting"),
3345
3487
  capabilities: z14.record(z14.string(), z14.unknown()).optional().describe("Additional capabilities to merge")
3346
3488
  }
@@ -3426,12 +3568,15 @@ async function startBrowserSession(args) {
3426
3568
  windowHeight,
3427
3569
  capabilities: userCapabilities
3428
3570
  });
3571
+ const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
3429
3572
  const wdioBrowser = await remote({ ...connectionConfig, capabilities: mergedCapabilities });
3430
3573
  const { sessionId } = wdioBrowser;
3431
3574
  const sessionMetadata = {
3432
3575
  type: "browser",
3433
3576
  capabilities: mergedCapabilities,
3434
- isAttached: false
3577
+ isAttached: false,
3578
+ provider: args.provider ?? "local",
3579
+ tunnelHandle
3435
3580
  };
3436
3581
  registerSession(sessionId, wdioBrowser, sessionMetadata, {
3437
3582
  sessionId,
@@ -3473,6 +3618,7 @@ async function startMobileSession(args) {
3473
3618
  const provider = getProvider(args.provider ?? "local", args.platform);
3474
3619
  const serverConfig = provider.getConnectionConfig(args);
3475
3620
  const mergedCapabilities = provider.buildCapabilities(args);
3621
+ const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
3476
3622
  const browser = await remote({ ...serverConfig, capabilities: mergedCapabilities });
3477
3623
  const { sessionId } = browser;
3478
3624
  const shouldAutoDetach = provider.shouldAutoDetach(args);
@@ -3480,7 +3626,9 @@ async function startMobileSession(args) {
3480
3626
  const metadata = {
3481
3627
  type: sessionType,
3482
3628
  capabilities: mergedCapabilities,
3483
- isAttached: shouldAutoDetach
3629
+ isAttached: shouldAutoDetach,
3630
+ provider: args.provider ?? "local",
3631
+ tunnelHandle
3484
3632
  };
3485
3633
  registerSession(sessionId, browser, metadata, {
3486
3634
  sessionId,
@@ -3526,7 +3674,8 @@ async function attachBrowserSession(args) {
3526
3674
  const sessionMetadata = {
3527
3675
  type: "browser",
3528
3676
  capabilities,
3529
- isAttached: true
3677
+ isAttached: true,
3678
+ provider: "local"
3530
3679
  };
3531
3680
  registerSession(sessionId, browser, sessionMetadata, {
3532
3681
  sessionId,