@wdio/mcp 3.5.1 → 3.6.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/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.5.0",
49
+ version: "3.5.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",
@@ -91,12 +91,14 @@ var package_default = {
91
91
  test: "vitest run"
92
92
  },
93
93
  dependencies: {
94
+ "@lambdatest/node-tunnel": "^4.0.11",
94
95
  "@modelcontextprotocol/sdk": "^1.27.1",
95
96
  "@toon-format/toon": "^2.1.0",
96
97
  "@wdio/protocols": "^9.27.0",
97
98
  "@xmldom/xmldom": "^0.9.10",
98
99
  "browserstack-local": "^1.5.12",
99
100
  "puppeteer-core": "^24.40.0",
101
+ saucelabs: "^9.0.2",
100
102
  sharp: "^0.34.5",
101
103
  webdriverio: "^9.27.0",
102
104
  xpath: "^0.0.34",
@@ -2427,6 +2429,134 @@ var browserstackLocalBinaryResource = {
2427
2429
  }
2428
2430
  };
2429
2431
 
2432
+ // src/resources/saucelabs-local.resource.ts
2433
+ function getLocalBinaryInfo2() {
2434
+ const platform2 = process.platform;
2435
+ const arch = process.arch;
2436
+ if (platform2 === "darwin") {
2437
+ return {
2438
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-osx.zip",
2439
+ platform: "macOS",
2440
+ arch: arch === "arm64" ? "Apple Silicon" : "Intel x64",
2441
+ binaryName: "sc"
2442
+ };
2443
+ }
2444
+ if (platform2 === "win32") {
2445
+ return {
2446
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-win32.zip",
2447
+ platform: "Windows",
2448
+ arch: "x86/x64",
2449
+ binaryName: "sc.exe"
2450
+ };
2451
+ }
2452
+ return {
2453
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-linux.tar.gz",
2454
+ platform: "Linux",
2455
+ arch: arch === "arm64" ? "ARM64" : "x64",
2456
+ binaryName: "sc"
2457
+ };
2458
+ }
2459
+ var saucelabsLocalBinaryResource = {
2460
+ name: "saucelabs-local-binary",
2461
+ uri: "wdio://saucelabs/local-binary",
2462
+ description: "Sauce Connect Proxy binary download URL and daemon setup instructions for the current platform. Only needed for tunnel: 'external' \u2014 when tunnel: true the SDK auto-manages Sauce Connect for you.",
2463
+ handler: async () => {
2464
+ const info = getLocalBinaryInfo2();
2465
+ const username = process.env.SAUCE_USERNAME ?? "<SAUCE_USERNAME>";
2466
+ const accessKey = process.env.SAUCE_ACCESS_KEY ?? "<SAUCE_ACCESS_KEY>";
2467
+ const region = process.env.SAUCE_REGION ?? "eu-central-1";
2468
+ const content = {
2469
+ requirement: "ONLY needed for tunnel: 'external'. Use tunnel: true instead \u2014 the SDK starts and stops Sauce Connect automatically. With tunnel: 'external', you MUST start the daemon manually BEFORE calling start_session, and set tunnelName to match the running tunnel.",
2470
+ platform: info.platform,
2471
+ arch: info.arch,
2472
+ downloadUrl: info.downloadUrl,
2473
+ ...info.note ? { note: info.note } : {},
2474
+ setup: [
2475
+ `1. Download: curl -O ${info.downloadUrl}`,
2476
+ `2. Unzip: ${info.downloadUrl.endsWith(".tar.gz") ? `tar -xzf ${info.downloadUrl.split("/").pop()}` : `unzip ${info.downloadUrl.split("/").pop()}`}`,
2477
+ `3. Make executable (macOS/Linux): chmod +x ${info.binaryName}`,
2478
+ `4. Start daemon: ./${info.binaryName} -u ${username} -k ${accessKey} --region ${region}`
2479
+ ],
2480
+ commands: {
2481
+ start: `./${info.binaryName} -u ${username} -k ${accessKey} --region ${region}`,
2482
+ stop: `./${info.binaryName} --stop`,
2483
+ status: `./${info.binaryName} --status`
2484
+ },
2485
+ afterDaemonIsRunning: "Call start_session with tunnel: 'external' and tunnelName matching this daemon to route traffic through it."
2486
+ };
2487
+ return {
2488
+ contents: [{
2489
+ uri: "wdio://saucelabs/local-binary",
2490
+ mimeType: "application/json",
2491
+ text: JSON.stringify(content, null, 2)
2492
+ }]
2493
+ };
2494
+ }
2495
+ };
2496
+
2497
+ // src/resources/testmu-local.resource.ts
2498
+ function getLocalBinaryInfo3() {
2499
+ const platform2 = process.platform;
2500
+ const arch = process.arch;
2501
+ if (platform2 === "darwin") {
2502
+ return {
2503
+ downloadUrl: "https://downloads.lambdatest.com/tunnel/v4/darwin/64bit/LT_Darwin.zip",
2504
+ platform: "macOS",
2505
+ arch: arch === "arm64" ? "Apple Silicon (via Rosetta 2)" : "Intel x64",
2506
+ binaryName: "LT"
2507
+ };
2508
+ }
2509
+ if (platform2 === "win32") {
2510
+ return {
2511
+ downloadUrl: "https://downloads.lambdatest.com/tunnel/v4/win/64bit/LT_Windows.zip",
2512
+ platform: "Windows",
2513
+ arch: "x86/x64",
2514
+ binaryName: "LT.exe"
2515
+ };
2516
+ }
2517
+ return {
2518
+ downloadUrl: "https://downloads.lambdatest.com/tunnel/v4/linux/64bit/LT_Linux.zip",
2519
+ platform: "Linux",
2520
+ arch: arch === "arm64" ? "ARM64" : "x64",
2521
+ binaryName: "LT"
2522
+ };
2523
+ }
2524
+ var testmuLocalBinaryResource = {
2525
+ name: "testmu-local-binary",
2526
+ uri: "wdio://testmu/local-binary",
2527
+ description: "TestMu Tunnel binary download URL and daemon setup instructions for the current platform. MUST be read and followed before using tunnel: true in start_session with provider: testmu.",
2528
+ handler: async () => {
2529
+ const info = getLocalBinaryInfo3();
2530
+ const username = process.env.TESTMU_USERNAME ?? "<TESTMU_USERNAME>";
2531
+ const accessKey = process.env.TESTMU_ACCESS_KEY ?? "<TESTMU_ACCESS_KEY>";
2532
+ const content = {
2533
+ requirement: "MUST start the TestMu Tunnel daemon BEFORE calling start_session with tunnel: true. Without it, all navigation to local/internal URLs will fail.",
2534
+ platform: info.platform,
2535
+ arch: info.arch,
2536
+ downloadUrl: info.downloadUrl,
2537
+ setup: [
2538
+ `1. Download: curl -O ${info.downloadUrl}`,
2539
+ `2. Unzip: unzip ${info.downloadUrl.split("/").pop()}`,
2540
+ `3. Make executable (macOS/Linux): chmod +x ${info.binaryName}`,
2541
+ `4. Start daemon: ./${info.binaryName} --user ${username} --key ${accessKey}`
2542
+ ],
2543
+ commands: {
2544
+ start: `./${info.binaryName} --user ${username} --key ${accessKey}`,
2545
+ stop: `./${info.binaryName} --user ${username} --key ${accessKey} --stop`,
2546
+ status: `./${info.binaryName} --status`
2547
+ },
2548
+ afterDaemonIsRunning: "Call start_session with tunnel: true and provider: testmu to route TestMu traffic through the tunnel."
2549
+ };
2550
+ return {
2551
+ contents: [{
2552
+ uri: "wdio://testmu/local-binary",
2553
+ mimeType: "application/json",
2554
+ text: JSON.stringify(content, null, 2)
2555
+ }]
2556
+ };
2557
+ }
2558
+ };
2559
+
2430
2560
  // src/resources/sessions.resource.ts
2431
2561
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2432
2562
 
@@ -2453,6 +2583,8 @@ function generateStep(step, history) {
2453
2583
  case "start_session": {
2454
2584
  const platform2 = p.platform;
2455
2585
  const isBrowserStack = "bstack:options" in history.capabilities;
2586
+ const isSauceLabs = "sauce:options" in history.capabilities;
2587
+ const isLambdaTest = "lt:options" in history.capabilities;
2456
2588
  const capJson = indentJson(history.capabilities).split("\n").map((line, i) => i > 0 ? ` ${line}` : line).join("\n");
2457
2589
  if (isBrowserStack) {
2458
2590
  const nav = platform2 === "browser" && p.navigationUrl ? `
@@ -2469,6 +2601,39 @@ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2469
2601
  `});${nav}`
2470
2602
  ].join("\n");
2471
2603
  }
2604
+ if (isSauceLabs) {
2605
+ const region = history.capabilities["sauce:options"]?.region ?? "eu-central-1";
2606
+ const nav = platform2 === "browser" && p.navigationUrl ? `
2607
+ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2608
+ return [
2609
+ "const browser = await remote({",
2610
+ " protocol: 'https',",
2611
+ ` hostname: 'ondemand.${region}.saucelabs.com',`,
2612
+ " port: 443,",
2613
+ " path: '/wd/hub',",
2614
+ " user: process.env.SAUCE_USERNAME,",
2615
+ " key: process.env.SAUCE_ACCESS_KEY,",
2616
+ ` capabilities: ${capJson}`,
2617
+ `});${nav}`
2618
+ ].join("\n");
2619
+ }
2620
+ if (isLambdaTest) {
2621
+ const isBrowser = platform2 === "browser";
2622
+ const hostname = isBrowser ? "hub.lambdatest.com" : "mobile-hub.lambdatest.com";
2623
+ const nav = platform2 === "browser" && p.navigationUrl ? `
2624
+ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2625
+ return [
2626
+ "const browser = await remote({",
2627
+ " protocol: 'https',",
2628
+ ` hostname: '${hostname}',`,
2629
+ " port: 443,",
2630
+ " path: '/wd/hub',",
2631
+ " user: process.env.TESTMU_USERNAME,",
2632
+ " key: process.env.TESTMU_ACCESS_KEY,",
2633
+ ` capabilities: ${capJson}`,
2634
+ `});${nav}`
2635
+ ].join("\n");
2636
+ }
2472
2637
  if (platform2 === "browser") {
2473
2638
  const nav = p.navigationUrl ? `
2474
2639
  await browser.url('${escapeStr(p.navigationUrl)}');` : "";
@@ -2531,8 +2696,12 @@ function bsStatusUpdateLines(sessionType) {
2531
2696
  }
2532
2697
  function generateCode(history) {
2533
2698
  const bstackOptions = history.capabilities["bstack:options"];
2699
+ const sauceOptions = history.capabilities["sauce:options"];
2700
+ const ltOptions = history.capabilities["lt:options"];
2534
2701
  const isBrowserStack = bstackOptions !== void 0;
2535
- const usesBrowserstackLocal = bstackOptions?.local === true;
2702
+ const isSauceLabs = sauceOptions !== void 0;
2703
+ const isLambdaTest = ltOptions !== void 0;
2704
+ const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true : isSauceLabs ? sauceOptions?.tunnelName !== void 0 : ltOptions?.tunnel === true;
2536
2705
  const steps = history.steps.map((step) => generateStep(step, history)).join("\n").split("\n").map((line) => ` ${line}`).join("\n");
2537
2706
  if (isBrowserStack) {
2538
2707
  const bsSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
@@ -2543,7 +2712,7 @@ function generateCode(history) {
2543
2712
  ${statusUpdate}
2544
2713
  await browser.deleteSession();
2545
2714
  }`;
2546
- if (usesBrowserstackLocal) {
2715
+ if (usesLocalTunnel) {
2547
2716
  const tunnelSetup = [
2548
2717
  "",
2549
2718
  "const tunnel = new BrowserstackTunnel();",
@@ -2579,6 +2748,119 @@ ${statusUpdate}
2579
2748
  "}"
2580
2749
  ].join("\n");
2581
2750
  }
2751
+ if (isSauceLabs) {
2752
+ const slSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
2753
+ const preamble = "let browser;\nlet slStatus = 'passed';\nlet slReason;";
2754
+ const catchBlock = "} catch (e) {\n slStatus = 'failed';\n slReason = String(e);\n throw e;";
2755
+ const slRegion = sauceOptions?.region ?? "eu-central-1";
2756
+ const statusUpdate = [
2757
+ " const slAuth = Buffer.from(`${process.env.SAUCE_USERNAME}:${process.env.SAUCE_ACCESS_KEY}`).toString('base64');",
2758
+ ` await fetch(\`https://api.${slRegion}.saucelabs.com/rest/v1/\${process.env.SAUCE_USERNAME}/jobs/\` + browser.sessionId, {`,
2759
+ " method: 'PUT',",
2760
+ " headers: { Authorization: 'Basic ' + slAuth, 'Content-Type': 'application/json' },",
2761
+ " body: JSON.stringify({ passed: slStatus === 'passed' })",
2762
+ " });"
2763
+ ].join("\n");
2764
+ const finallyLines = [
2765
+ " if (browser) {",
2766
+ statusUpdate,
2767
+ " await browser.deleteSession();",
2768
+ " }"
2769
+ ];
2770
+ if (usesLocalTunnel) {
2771
+ const tunnelSetup = [
2772
+ "",
2773
+ "import SauceLabs from 'saucelabs';",
2774
+ "",
2775
+ "const sl = new SauceLabs({",
2776
+ " user: process.env.SAUCE_USERNAME,",
2777
+ " key: process.env.SAUCE_ACCESS_KEY,",
2778
+ ` region: '${slRegion}',`,
2779
+ "});",
2780
+ "const sc = await sl.startSauceConnect({ tunnelName: 'wdio-mcp-tunnel' });",
2781
+ "const stopTunnel = () => sc.close();",
2782
+ ""
2783
+ ].join("\n");
2784
+ return [
2785
+ "import { remote } from 'webdriverio';",
2786
+ tunnelSetup,
2787
+ preamble,
2788
+ "try {",
2789
+ slSteps,
2790
+ catchBlock,
2791
+ "} finally {",
2792
+ ...finallyLines,
2793
+ " await stopTunnel();",
2794
+ "}"
2795
+ ].join("\n");
2796
+ }
2797
+ return [
2798
+ "import { remote } from 'webdriverio';",
2799
+ "",
2800
+ preamble,
2801
+ "try {",
2802
+ slSteps,
2803
+ catchBlock,
2804
+ "} finally {",
2805
+ ...finallyLines,
2806
+ "}"
2807
+ ].join("\n");
2808
+ }
2809
+ if (isLambdaTest) {
2810
+ const ltSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
2811
+ const preamble = "let browser;\nlet ltStatus = 'passed';";
2812
+ const catchBlock = "} catch (e) {\n ltStatus = 'failed';\n throw e;";
2813
+ const isMobile = history.type !== "browser";
2814
+ const statusUpdate = isMobile ? " await browser.execute('lambda-status=' + ltStatus);" : [
2815
+ " const ltAuth = Buffer.from(`${process.env.TESTMU_USERNAME}:${process.env.TESTMU_ACCESS_KEY}`).toString('base64');",
2816
+ " await fetch('https://api.lambdatest.com/automation/api/v1/sessions/' + browser.sessionId, {",
2817
+ " method: 'PATCH',",
2818
+ " headers: { Authorization: 'Basic ' + ltAuth, 'Content-Type': 'application/json' },",
2819
+ " body: JSON.stringify({ status_ind: ltStatus })",
2820
+ " });"
2821
+ ].join("\n");
2822
+ const finallyLines = [
2823
+ " if (browser) {",
2824
+ statusUpdate,
2825
+ " await browser.deleteSession();",
2826
+ " }"
2827
+ ];
2828
+ if (usesLocalTunnel) {
2829
+ const tunnelName = ltOptions?.tunnelName ?? "wdio-mcp-tunnel";
2830
+ const tunnelSetup = [
2831
+ "",
2832
+ "import LambdaTunnel from '@lambdatest/node-tunnel';",
2833
+ "",
2834
+ "const tunnel = new LambdaTunnel();",
2835
+ `await tunnel.start({ user: process.env.TESTMU_USERNAME, key: process.env.TESTMU_ACCESS_KEY, tunnelName: '${escapeStr(tunnelName)}' });`,
2836
+ "const stopTunnel = () => tunnel.stop();",
2837
+ ""
2838
+ ].join("\n");
2839
+ return [
2840
+ "import { remote } from 'webdriverio';",
2841
+ tunnelSetup,
2842
+ preamble,
2843
+ "try {",
2844
+ ltSteps,
2845
+ catchBlock,
2846
+ "} finally {",
2847
+ ...finallyLines,
2848
+ " await stopTunnel();",
2849
+ "}"
2850
+ ].join("\n");
2851
+ }
2852
+ return [
2853
+ "import { remote } from 'webdriverio';",
2854
+ "",
2855
+ preamble,
2856
+ "try {",
2857
+ ltSteps,
2858
+ catchBlock,
2859
+ "} finally {",
2860
+ ...finallyLines,
2861
+ "}"
2862
+ ].join("\n");
2863
+ }
2582
2864
  return `import { remote } from 'webdriverio';
2583
2865
 
2584
2866
  try {
@@ -3276,7 +3558,7 @@ import { z as z14 } from "zod";
3276
3558
  // src/session/lifecycle.ts
3277
3559
  init_state();
3278
3560
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3279
- import { join as join3 } from "path";
3561
+ import { join as join5 } from "path";
3280
3562
 
3281
3563
  // src/providers/local-browser.provider.ts
3282
3564
  var LocalBrowserProvider = class {
@@ -3531,6 +3813,13 @@ import { promisify } from "util";
3531
3813
  import { tmpdir as tmpdir2 } from "os";
3532
3814
  import { join as join2 } from "path";
3533
3815
  import { Local as BrowserstackTunnel } from "browserstack-local";
3816
+
3817
+ // src/utils/auth.ts
3818
+ function basicAuth(user, key) {
3819
+ return Buffer.from(`${user}:${key}`).toString("base64");
3820
+ }
3821
+
3822
+ // src/providers/cloud/browserstack.provider.ts
3534
3823
  var BrowserStackProvider = class {
3535
3824
  name = "browserstack";
3536
3825
  getConnectionConfig(options) {
@@ -3548,7 +3837,7 @@ var BrowserStackProvider = class {
3548
3837
  buildCapabilities(options) {
3549
3838
  const platform2 = options.platform;
3550
3839
  const userCapabilities = options.capabilities ?? {};
3551
- const browserstackLocal = options.browserstackLocal;
3840
+ const browserstackLocal = options.tunnel ?? options.browserstackLocal;
3552
3841
  if (platform2 === "browser") {
3553
3842
  const bstackOptions2 = {
3554
3843
  browserVersion: options.browserVersion ?? "latest"
@@ -3561,9 +3850,9 @@ var BrowserStackProvider = class {
3561
3850
  if (reporting2?.build) bstackOptions2.buildName = reporting2.build;
3562
3851
  if (reporting2?.session) bstackOptions2.sessionName = reporting2.session;
3563
3852
  return {
3853
+ ...userCapabilities,
3564
3854
  browserName: options.browser ?? "chrome",
3565
- "bstack:options": bstackOptions2,
3566
- ...userCapabilities
3855
+ "bstack:options": bstackOptions2
3567
3856
  };
3568
3857
  }
3569
3858
  const bstackOptions = {
@@ -3581,14 +3870,14 @@ var BrowserStackProvider = class {
3581
3870
  const autoAcceptAlerts = options.autoAcceptAlerts;
3582
3871
  const autoDismissAlerts = options.autoDismissAlerts;
3583
3872
  return {
3873
+ ...userCapabilities,
3584
3874
  platformName: platform2,
3585
3875
  "appium:app": options.app,
3586
3876
  "appium:autoGrantPermissions": options.autoGrantPermissions ?? true,
3587
3877
  "appium:autoAcceptAlerts": autoDismissAlerts ? void 0 : autoAcceptAlerts ?? true,
3588
3878
  "appium:autoDismissAlerts": autoDismissAlerts,
3589
3879
  "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
3590
- "bstack:options": bstackOptions,
3591
- ...userCapabilities
3880
+ "bstack:options": bstackOptions
3592
3881
  };
3593
3882
  }
3594
3883
  getSessionType(options) {
@@ -3626,7 +3915,7 @@ var BrowserStackProvider = class {
3626
3915
  const key = process.env.BROWSERSTACK_ACCESS_KEY;
3627
3916
  if (!user || !key) return;
3628
3917
  const baseUrl = sessionType === "browser" ? "https://api.browserstack.com/automate/sessions" : "https://api-cloud.browserstack.com/app-automate/sessions";
3629
- const auth = Buffer.from(`${user}:${key}`).toString("base64");
3918
+ const auth = basicAuth(user, key);
3630
3919
  const body = { status: result.status, ...result.reason ? { reason: result.reason } : {} };
3631
3920
  await fetch(`${baseUrl}/${sessionId}.json`, {
3632
3921
  method: "PUT",
@@ -3637,11 +3926,305 @@ var BrowserStackProvider = class {
3637
3926
  };
3638
3927
  var browserStackProvider = new BrowserStackProvider();
3639
3928
 
3929
+ // src/providers/cloud/saucelabs.provider.ts
3930
+ import SauceLabs from "saucelabs";
3931
+ import { tmpdir as tmpdir3 } from "os";
3932
+ import { join as join3 } from "path";
3933
+ var SauceLabsProvider = class {
3934
+ name = "saucelabs";
3935
+ resolveRegion(options) {
3936
+ return options.region ?? "eu-central-1";
3937
+ }
3938
+ getConnectionConfig(options) {
3939
+ const region = this.resolveRegion(options);
3940
+ return {
3941
+ protocol: "https",
3942
+ hostname: `ondemand.${region}.saucelabs.com`,
3943
+ port: 443,
3944
+ path: "/wd/hub",
3945
+ user: process.env.SAUCE_USERNAME,
3946
+ key: process.env.SAUCE_ACCESS_KEY
3947
+ };
3948
+ }
3949
+ buildCapabilities(options) {
3950
+ const platform2 = options.platform;
3951
+ const region = this.resolveRegion(options);
3952
+ const userCapabilities = options.capabilities ?? {};
3953
+ const saucelabsLocal = options.tunnel ?? options.saucelabsLocal;
3954
+ const reporting = options.reporting;
3955
+ const sauceOptions = { region };
3956
+ if (reporting?.build) sauceOptions.build = reporting.build;
3957
+ if (reporting?.session) sauceOptions.name = reporting.session;
3958
+ else if (reporting?.project) sauceOptions.name = reporting.project;
3959
+ if (saucelabsLocal) {
3960
+ sauceOptions.tunnelName = options.tunnelName;
3961
+ }
3962
+ if (platform2 === "browser") {
3963
+ return {
3964
+ ...userCapabilities,
3965
+ browserName: options.browser ?? "chrome",
3966
+ browserVersion: options.browserVersion ?? "latest",
3967
+ platformName: options.os ? [options.os, options.osVersion].filter(Boolean).join(" ") : "Linux",
3968
+ "sauce:options": sauceOptions
3969
+ };
3970
+ }
3971
+ const mobileBrowser = options.browser;
3972
+ if (mobileBrowser) {
3973
+ if (options.deviceOrientation) sauceOptions.deviceOrientation = options.deviceOrientation;
3974
+ const caps = {
3975
+ platformName: platform2,
3976
+ browserName: mobileBrowser,
3977
+ "appium:deviceName": options.deviceName,
3978
+ "appium:platformVersion": options.platformVersion,
3979
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
3980
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
3981
+ "sauce:options": sauceOptions
3982
+ };
3983
+ return { ...userCapabilities, ...caps };
3984
+ }
3985
+ sauceOptions.appiumVersion = "latest";
3986
+ const autoAcceptAlerts = options.autoAcceptAlerts;
3987
+ const autoDismissAlerts = options.autoDismissAlerts;
3988
+ return {
3989
+ ...userCapabilities,
3990
+ platformName: platform2,
3991
+ "appium:app": options.app,
3992
+ "appium:deviceName": options.deviceName,
3993
+ "appium:platformVersion": options.platformVersion,
3994
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
3995
+ "appium:autoGrantPermissions": options.autoGrantPermissions ?? true,
3996
+ "appium:autoAcceptAlerts": autoDismissAlerts ? void 0 : autoAcceptAlerts ?? true,
3997
+ "appium:autoDismissAlerts": autoDismissAlerts,
3998
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
3999
+ "sauce:options": sauceOptions
4000
+ };
4001
+ }
4002
+ getSessionType(options) {
4003
+ const platform2 = options.platform;
4004
+ if (platform2 === "browser") return "browser";
4005
+ return platform2;
4006
+ }
4007
+ shouldAutoDetach(_options) {
4008
+ return false;
4009
+ }
4010
+ async startTunnel(options) {
4011
+ const region = this.resolveRegion(options);
4012
+ const tunnelName = options.tunnelName ?? `wdio-mcp-${Date.now()}`;
4013
+ const logFile = join3(tmpdir3(), "sauce-connect.log");
4014
+ console.error(`[SauceLabs] Starting tunnel "${tunnelName}" (region: ${region})`);
4015
+ try {
4016
+ const api = new SauceLabs({ user: process.env.SAUCE_USERNAME ?? "", key: process.env.SAUCE_ACCESS_KEY ?? "", region });
4017
+ return api.startSauceConnect({
4018
+ tunnelName,
4019
+ logFile,
4020
+ logger: (msg) => console.error(`[SauceConnect] ${msg}`)
4021
+ });
4022
+ } catch (e) {
4023
+ const msg = (e !== null && typeof e === "object" ? e.message : void 0) ?? String(e);
4024
+ if (msg.includes("already running") || msg.includes("another instance")) {
4025
+ console.error("[SauceLabs] Tunnel already running \u2014 reusing existing tunnel");
4026
+ return null;
4027
+ }
4028
+ throw e;
4029
+ }
4030
+ }
4031
+ async onSessionClose(sessionId, _sessionType, result, _tunnelHandle, _browser, region) {
4032
+ const effectiveRegion = region ?? "eu-central-1";
4033
+ const user = process.env.SAUCE_USERNAME;
4034
+ const key = process.env.SAUCE_ACCESS_KEY;
4035
+ if (user && key) {
4036
+ try {
4037
+ const auth = basicAuth(user, key);
4038
+ const body = { passed: result.status === "passed" };
4039
+ const apiUrl = `https://api.${effectiveRegion}.saucelabs.com/rest/v1/${user}/jobs/${sessionId}`;
4040
+ console.error(`[SauceLabs] Setting job status for ${sessionId}: ${result.status}`);
4041
+ await fetch(apiUrl, {
4042
+ method: "PUT",
4043
+ headers: {
4044
+ Authorization: `Basic ${auth}`,
4045
+ "Content-Type": "application/json"
4046
+ },
4047
+ body: JSON.stringify(body)
4048
+ });
4049
+ console.error("[SauceLabs] Job result set successfully via REST API");
4050
+ } catch (e) {
4051
+ console.error("[SauceLabs] Failed to set job result via REST API:", e);
4052
+ }
4053
+ }
4054
+ }
4055
+ async stopTunnel(tunnelHandle) {
4056
+ if (tunnelHandle) {
4057
+ const sc = tunnelHandle;
4058
+ await sc.close();
4059
+ }
4060
+ }
4061
+ };
4062
+ var sauceLabsProvider = new SauceLabsProvider();
4063
+
4064
+ // src/providers/cloud/testmu.provider.ts
4065
+ import LambdaTunnel from "@lambdatest/node-tunnel";
4066
+ import { tmpdir as tmpdir4 } from "os";
4067
+ import { join as join4 } from "path";
4068
+ var TestMuProvider = class {
4069
+ name = "testmu";
4070
+ getConnectionConfig(options) {
4071
+ const platform2 = options.platform;
4072
+ const isBrowser = platform2 === "browser";
4073
+ return {
4074
+ protocol: "https",
4075
+ hostname: isBrowser ? "hub.lambdatest.com" : "mobile-hub.lambdatest.com",
4076
+ port: 443,
4077
+ path: "/wd/hub",
4078
+ user: process.env.TESTMU_USERNAME,
4079
+ key: process.env.TESTMU_ACCESS_KEY
4080
+ };
4081
+ }
4082
+ buildCapabilities(options) {
4083
+ const platform2 = options.platform;
4084
+ const userCapabilities = options.capabilities ?? {};
4085
+ const tunnel = options.tunnel ?? options.testmuLocal;
4086
+ const reporting = options.reporting;
4087
+ const ltOptions = { w3c: true };
4088
+ if (reporting?.project) ltOptions.project = reporting.project;
4089
+ if (reporting?.build) ltOptions.build = reporting.build;
4090
+ if (reporting?.session) ltOptions.name = reporting.session;
4091
+ else if (reporting?.project) ltOptions.name = reporting.project;
4092
+ if (tunnel) {
4093
+ ltOptions.tunnel = true;
4094
+ if (options.tunnelName) ltOptions.tunnelName = options.tunnelName;
4095
+ }
4096
+ if (platform2 === "browser") {
4097
+ return {
4098
+ ...userCapabilities,
4099
+ browserName: options.browser ?? "chrome",
4100
+ browserVersion: options.browserVersion ?? "latest",
4101
+ platformName: options.os ? [options.os, options.osVersion].filter(Boolean).join(" ") : "Linux",
4102
+ "lt:options": ltOptions
4103
+ };
4104
+ }
4105
+ const mobileBrowser = options.browser;
4106
+ if (mobileBrowser) {
4107
+ ltOptions.isRealMobile = false;
4108
+ if (options.deviceOrientation) ltOptions.deviceOrientation = options.deviceOrientation;
4109
+ const caps2 = {
4110
+ platformName: platform2,
4111
+ browserName: mobileBrowser,
4112
+ "appium:deviceName": options.deviceName,
4113
+ "appium:platformVersion": options.platformVersion,
4114
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
4115
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
4116
+ "lt:options": ltOptions
4117
+ };
4118
+ return { ...userCapabilities, ...caps2 };
4119
+ }
4120
+ ltOptions.appiumVersion = "latest";
4121
+ ltOptions.isRealMobile = true;
4122
+ const autoAcceptAlerts = options.autoAcceptAlerts;
4123
+ const autoDismissAlerts = options.autoDismissAlerts;
4124
+ const caps = {
4125
+ platformName: platform2,
4126
+ "appium:app": options.app,
4127
+ "appium:deviceName": options.deviceName,
4128
+ "appium:platformVersion": options.platformVersion,
4129
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
4130
+ "appium:autoGrantPermissions": options.autoGrantPermissions ?? true,
4131
+ "appium:autoAcceptAlerts": autoDismissAlerts ? void 0 : autoAcceptAlerts ?? true,
4132
+ "appium:autoDismissAlerts": autoDismissAlerts,
4133
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
4134
+ "lt:options": ltOptions
4135
+ };
4136
+ return { ...userCapabilities, ...caps };
4137
+ }
4138
+ getSessionType(options) {
4139
+ const platform2 = options.platform;
4140
+ if (platform2 === "browser") return "browser";
4141
+ return platform2;
4142
+ }
4143
+ shouldAutoDetach(_options) {
4144
+ return false;
4145
+ }
4146
+ async startTunnel(options) {
4147
+ const tunnelName = options.tunnelName ?? `wdio-mcp-testmu-${Date.now()}`;
4148
+ const logFile = join4(tmpdir4(), "testmu-tunnel.log");
4149
+ console.error(`[TestMu] Starting tunnel "${tunnelName}"`);
4150
+ try {
4151
+ const tunnel = new LambdaTunnel();
4152
+ await tunnel.start({
4153
+ user: process.env.TESTMU_USERNAME ?? "",
4154
+ key: process.env.TESTMU_ACCESS_KEY ?? "",
4155
+ tunnelName,
4156
+ logFile
4157
+ });
4158
+ console.error(`[TestMu] Tunnel started: "${tunnelName}"`);
4159
+ return tunnel;
4160
+ } catch (e) {
4161
+ const msg = (e !== null && typeof e === "object" ? e.message : void 0) ?? String(e);
4162
+ if (msg.includes("already running") || msg.includes("another tunnel") || msg.includes("already in use")) {
4163
+ console.error("[TestMu] Tunnel already running \u2014 reusing existing tunnel");
4164
+ return null;
4165
+ }
4166
+ throw e;
4167
+ }
4168
+ }
4169
+ async onSessionClose(sessionId, sessionType, result, _tunnelHandle, browser, _region) {
4170
+ const user = process.env.TESTMU_USERNAME;
4171
+ const key = process.env.TESTMU_ACCESS_KEY;
4172
+ if (!user || !key) return;
4173
+ const status = result.status === "passed" ? "passed" : "failed";
4174
+ if (sessionType !== "browser") {
4175
+ try {
4176
+ console.error(`[TestMu] Setting mobile session status for ${sessionId}: ${status}`);
4177
+ await browser?.execute("lambda-status=" + status);
4178
+ console.error("[TestMu] Mobile session status set successfully via execute");
4179
+ } catch (e) {
4180
+ console.error("[TestMu] Failed to set mobile session status via execute:", e);
4181
+ }
4182
+ return;
4183
+ }
4184
+ try {
4185
+ const auth = basicAuth(user, key);
4186
+ const body = { status_ind: status };
4187
+ const apiUrl = `https://api.lambdatest.com/automation/api/v1/sessions/${sessionId}`;
4188
+ console.error(`[TestMu] Setting session status for ${sessionId}: ${body.status_ind}`);
4189
+ const res = await fetch(apiUrl, {
4190
+ method: "PATCH",
4191
+ headers: {
4192
+ Authorization: `Basic ${auth}`,
4193
+ "Content-Type": "application/json"
4194
+ },
4195
+ body: JSON.stringify(body)
4196
+ });
4197
+ if (res.ok) {
4198
+ console.error("[TestMu] Session status set successfully via REST API");
4199
+ } else {
4200
+ const resBody = await res.text();
4201
+ console.error(`[TestMu] Failed to set session status: HTTP ${res.status} \u2014 ${resBody}`);
4202
+ }
4203
+ } catch (e) {
4204
+ console.error("[TestMu] Failed to set session status via REST API:", e);
4205
+ }
4206
+ }
4207
+ async stopTunnel(tunnelHandle) {
4208
+ if (tunnelHandle) {
4209
+ const tunnel = tunnelHandle;
4210
+ await tunnel.stop();
4211
+ }
4212
+ }
4213
+ };
4214
+ var testMuProvider = new TestMuProvider();
4215
+
3640
4216
  // src/providers/registry.ts
3641
- function getProvider(providerName, platform2) {
3642
- if (providerName === "browserstack") return browserStackProvider;
4217
+ var providers = /* @__PURE__ */ new Map([
4218
+ ["browserstack", browserStackProvider],
4219
+ ["saucelabs", sauceLabsProvider],
4220
+ ["testmu", testMuProvider]
4221
+ ]);
4222
+ function getDefaultProvider(platform2) {
3643
4223
  return platform2 === "browser" ? localBrowserProvider : localAppiumProvider;
3644
4224
  }
4225
+ function getProvider(providerName, platform2) {
4226
+ return providers.get(providerName) ?? getDefaultProvider(platform2);
4227
+ }
3645
4228
 
3646
4229
  // src/trace/zip-writer.ts
3647
4230
  import yazl from "yazl";
@@ -3671,10 +4254,10 @@ async function finalizeTrace(sessionId, browser) {
3671
4254
  if (!traceSession) return;
3672
4255
  try {
3673
4256
  await traceSession.screenshotChain;
3674
- const traceDir = join3(process.cwd(), ".trace");
4257
+ const traceDir = join5(process.cwd(), ".trace");
3675
4258
  mkdirSync2(traceDir, { recursive: true });
3676
4259
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3677
- const outPath = join3(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
4260
+ const outPath = join5(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
3678
4261
  const zipBuffer = await buildTraceZip(traceSession);
3679
4262
  writeFileSync2(outPath, zipBuffer);
3680
4263
  console.error(`[TRACE] Saved to ${outPath}`);
@@ -3726,11 +4309,16 @@ function registerSession(sessionId, browser, metadata, historyEntry) {
3726
4309
  if (oldMetadata?.provider) {
3727
4310
  const oldHistory = state2.sessionHistory.get(oldSessionId);
3728
4311
  const provider = getProvider(oldMetadata.provider, oldMetadata.type);
3729
- await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {
4312
+ await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle, oldBrowser, oldMetadata.region).catch(() => {
3730
4313
  });
3731
4314
  }
3732
4315
  await oldBrowser.deleteSession().catch(() => {
3733
4316
  });
4317
+ if (oldMetadata?.provider && oldMetadata?.tunnelHandle) {
4318
+ const provider = getProvider(oldMetadata.provider, oldMetadata.type);
4319
+ await provider.stopTunnel?.(oldMetadata.tunnelHandle).catch(() => {
4320
+ });
4321
+ }
3734
4322
  })();
3735
4323
  state2.browsers.delete(oldSessionId);
3736
4324
  state2.sessionMetadata.delete(oldSessionId);
@@ -3753,12 +4341,20 @@ async function closeSession(sessionId, detach, isAttached, force) {
3753
4341
  if (metadata?.provider) {
3754
4342
  try {
3755
4343
  const provider = getProvider(metadata.provider, metadata.type);
3756
- await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
4344
+ await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle, browser, metadata.region);
3757
4345
  } catch (e) {
3758
4346
  console.error("[WARN] Failed to run provider onSessionClose:", e);
3759
4347
  }
3760
4348
  }
3761
4349
  await browser.deleteSession();
4350
+ if (metadata?.provider && metadata?.tunnelHandle) {
4351
+ try {
4352
+ const provider = getProvider(metadata.provider, metadata.type);
4353
+ await provider.stopTunnel?.(metadata.tunnelHandle);
4354
+ } catch (e) {
4355
+ console.error("[WARN] Failed to stop tunnel:", e);
4356
+ }
4357
+ }
3762
4358
  }
3763
4359
  state2.browsers.delete(sessionId);
3764
4360
  state2.sessionMetadata.delete(sessionId);
@@ -3776,23 +4372,23 @@ var startSessionToolDefinition = {
3776
4372
  description: 'Starts a browser or mobile automation session. Only one active session at a time \u2014 starting a new one closes the existing session first. Use platform "browser" with a browser name, or "ios"/"android" with deviceName. Set attach: true to connect to a running Chrome via CDP instead of launching a new browser.',
3777
4373
  annotations: { title: "Start Session", destructiveHint: false },
3778
4374
  inputSchema: {
3779
- provider: z14.enum(["local", "browserstack"]).optional().default("local").describe("Session provider (default: local)"),
4375
+ provider: z14.enum(["local", "browserstack", "saucelabs", "testmu"]).optional().default("local").describe("Session provider (default: local)"),
3780
4376
  platform: platformEnum.describe("Session platform type"),
3781
4377
  browser: browserEnum.optional().describe("Browser to launch (required for browser platform)"),
3782
- browserVersion: z14.string().optional().describe("Browser version (BrowserStack only, default: latest)"),
3783
- os: z14.string().optional().describe('Operating system (BrowserStack browser only, e.g. "Windows", "OS X")'),
3784
- osVersion: z14.string().optional().describe('OS version (BrowserStack browser only, e.g. "11", "Sequoia")'),
3785
- app: z14.string().optional().describe("BrowserStack app URL (bs://...) or custom_id for mobile sessions"),
4378
+ browserVersion: z14.string().optional().describe("Browser version (cloud providers only, default: latest)"),
4379
+ os: z14.string().optional().describe('Operating system for cloud provider browser sessions (e.g. "Windows", "Mac", "macOS", "Linux"). BrowserStack: sets bstack:options.os separately. TestMu/Sauce Labs: combined with osVersion into W3C platformName. Browser platform only.'),
4380
+ osVersion: z14.string().optional().describe('OS version for cloud provider browser sessions (e.g. "11", "15", "Monterey"). BrowserStack: sets bstack:options.osVersion separately. TestMu/Sauce Labs: combined with os into W3C platformName. Browser platform only.'),
4381
+ app: z14.string().optional().describe("App URL (bs://... for BrowserStack, storage:filename= for Sauce Labs, lt://... for TestMu mobile sessions)"),
3786
4382
  reporting: z14.object({
3787
4383
  project: z14.string().optional(),
3788
4384
  build: z14.string().optional(),
3789
4385
  session: z14.string().optional()
3790
- }).optional().describe("BrowserStack reporting labels (project, build, session)"),
4386
+ }).optional().describe("Cloud provider reporting labels (project, build, session)"),
3791
4387
  headless: coerceBoolean.optional().default(true).describe("Run browser in headless mode (default: true)"),
3792
4388
  windowWidth: z14.number().min(400).max(3840).optional().default(1920).describe("Browser window width"),
3793
4389
  windowHeight: z14.number().min(400).max(2160).optional().default(1080).describe("Browser window height"),
3794
4390
  deviceName: z14.string().optional().describe("Mobile device/emulator/simulator name (required for ios/android)"),
3795
- platformVersion: z14.string().optional().describe('OS version (e.g., "17.0", "14")'),
4391
+ platformVersion: z14.string().optional().describe('OS version for mobile sessions (e.g., "17.0", "14"). Mobile (ios/android) only.'),
3796
4392
  appPath: z14.string().optional().describe("Path to app file (.app/.apk/.ipa)"),
3797
4393
  automationName: automationEnum.optional().describe("Automation driver"),
3798
4394
  autoGrantPermissions: coerceBoolean.optional().describe("Auto-grant app permissions (default: true)"),
@@ -3815,7 +4411,12 @@ var startSessionToolDefinition = {
3815
4411
  path: z14.string().optional(),
3816
4412
  protocol: z14.string().optional()
3817
4413
  }).optional().describe("Appium server connection (local provider only)"),
3818
- 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.'),
4414
+ region: z14.enum(["us-west-1", "eu-central-1", "apac-southeast-1"]).optional().default("eu-central-1").describe('Sauce Labs region (default: eu-central-1). Only used with provider: "saucelabs".'),
4415
+ tunnel: z14.union([z14.literal("external"), coerceBoolean]).optional().default(false).describe('Enable local tunnel routing (cloud providers only, default: false). true = auto-start tunnel before session and stop on close. "external" = tunnel already running externally.'),
4416
+ tunnelName: z14.string().optional().describe('Tunnel identifier name. With tunnel: "external" this must match the running tunnel. With tunnel: true a unique name is auto-generated if not provided.'),
4417
+ browserstackLocal: z14.union([z14.literal("external"), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable BrowserStack Local tunnel routing.'),
4418
+ saucelabsLocal: z14.union([z14.literal("external"), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable Sauce Connect tunnel routing.'),
4419
+ testmuLocal: z14.union([z14.literal("external"), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable TestMu Tunnel routing.'),
3819
4420
  navigationUrl: z14.string().optional().describe("URL to navigate to after starting"),
3820
4421
  capabilities: z14.record(z14.string(), z14.unknown()).optional().describe("Additional capabilities to merge")
3821
4422
  }
@@ -3894,15 +4495,19 @@ async function startBrowserSession(args) {
3894
4495
  const effectiveHeadless = headless && headlessSupported;
3895
4496
  const provider = getProvider(args.provider ?? "local", "browser");
3896
4497
  const connectionConfig = provider.getConnectionConfig(args);
4498
+ const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? args.testmuLocal ?? false;
4499
+ const tunnelEnabled = effectiveTunnel === true;
4500
+ const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName;
3897
4501
  const mergedCapabilities = provider.buildCapabilities({
3898
4502
  ...args,
3899
4503
  browser,
3900
4504
  headless,
3901
4505
  windowWidth,
3902
4506
  windowHeight,
3903
- capabilities: userCapabilities
4507
+ capabilities: userCapabilities,
4508
+ tunnelName
3904
4509
  });
3905
- const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
4510
+ const tunnelHandle = tunnelEnabled ? await provider.startTunnel?.({ ...args, tunnelName }) : void 0;
3906
4511
  const wdioBrowser = await remote({ ...connectionConfig, capabilities: mergedCapabilities });
3907
4512
  const { sessionId } = wdioBrowser;
3908
4513
  const sessionMetadata = {
@@ -3910,6 +4515,8 @@ async function startBrowserSession(args) {
3910
4515
  capabilities: mergedCapabilities,
3911
4516
  isAttached: false,
3912
4517
  provider: args.provider ?? "local",
4518
+ region: args.region,
4519
+ tunnelName,
3913
4520
  tunnelHandle,
3914
4521
  trace: args.trace ?? false
3915
4522
  };
@@ -3948,7 +4555,8 @@ Note: Unable to set window size (${windowWidth}x${windowHeight}). ${e}`;
3948
4555
  }
3949
4556
  async function startMobileSession(args) {
3950
4557
  const { platform: platform2, appPath, app, deviceName, noReset } = args;
3951
- if (!appPath && !app && noReset !== true) {
4558
+ const isMobileBrowser = args.browser !== void 0;
4559
+ if (!isMobileBrowser && !appPath && !app && noReset !== true) {
3952
4560
  return {
3953
4561
  content: [{
3954
4562
  type: "text",
@@ -3958,8 +4566,11 @@ async function startMobileSession(args) {
3958
4566
  }
3959
4567
  const provider = getProvider(args.provider ?? "local", args.platform);
3960
4568
  const serverConfig = provider.getConnectionConfig(args);
3961
- const mergedCapabilities = provider.buildCapabilities(args);
3962
- const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
4569
+ const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? args.testmuLocal ?? false;
4570
+ const tunnelEnabled = effectiveTunnel === true;
4571
+ const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName;
4572
+ const mergedCapabilities = provider.buildCapabilities({ ...args, tunnelName });
4573
+ const tunnelHandle = tunnelEnabled ? await provider.startTunnel?.({ ...args, tunnelName }) : void 0;
3963
4574
  const browser = await remote({ ...serverConfig, capabilities: mergedCapabilities });
3964
4575
  const { sessionId } = browser;
3965
4576
  const shouldAutoDetach = provider.shouldAutoDetach(args);
@@ -3969,6 +4580,8 @@ async function startMobileSession(args) {
3969
4580
  capabilities: mergedCapabilities,
3970
4581
  isAttached: shouldAutoDetach,
3971
4582
  provider: args.provider ?? "local",
4583
+ region: args.region,
4584
+ tunnelName,
3972
4585
  tunnelHandle,
3973
4586
  trace: args.trace ?? false
3974
4587
  };
@@ -3983,14 +4596,16 @@ async function startMobileSession(args) {
3983
4596
  if (args.trace) {
3984
4597
  startTrace(sessionId, mergedCapabilities, sessionType);
3985
4598
  }
3986
- const appInfo = appPath ? `
4599
+ const sessionKind = isMobileBrowser ? "mobile browser" : "app";
4600
+ const appInfo = isMobileBrowser ? `
4601
+ Browser: ${args.browser}` : appPath ? `
3987
4602
  App: ${appPath}` : "\nApp: (connected to running app)";
3988
4603
  const detachNote = shouldAutoDetach ? "\n\n(Auto-detach enabled: session will be preserved on close. Use close_session({ detach: false }) to force terminate.)" : "";
3989
4604
  return {
3990
4605
  content: [
3991
4606
  {
3992
4607
  type: "text",
3993
- text: `${platform2} app session started with sessionId: ${sessionId}
4608
+ text: `${platform2} ${sessionKind} session started with sessionId: ${sessionId}
3994
4609
  Device: ${deviceName}${appInfo}
3995
4610
  Appium Server: ${serverConfig.hostname}:${serverConfig.port}${serverConfig.path}${detachNote}`
3996
4611
  }
@@ -4152,51 +4767,163 @@ var switchFrameTool = async ({
4152
4767
  }
4153
4768
  };
4154
4769
 
4155
- // src/tools/browserstack.tool.ts
4156
- import { existsSync as existsSync2, createReadStream } from "fs";
4770
+ // src/tools/cloud-provider.tool.ts
4771
+ import { existsSync as existsSync2, readFileSync } from "fs";
4157
4772
  import { z as z17 } from "zod";
4158
- var BS_API = "https://api-cloud.browserstack.com";
4159
- function getAuth() {
4160
- const user = process.env.BROWSERSTACK_USERNAME;
4161
- const key = process.env.BROWSERSTACK_ACCESS_KEY;
4162
- if (!user || !key) return null;
4163
- return Buffer.from(`${user}:${key}`).toString("base64");
4164
- }
4165
4773
  function formatAppList(apps) {
4166
4774
  if (apps.length === 0) return "No apps found.";
4167
4775
  return apps.map((a) => {
4168
- const id = a.custom_id ? ` [${a.custom_id}]` : "";
4169
- return `${a.app_name} v${a.app_version}${id} \u2014 ${a.app_url} (${a.uploaded_at})`;
4776
+ const id = a.customId ? ` [${a.customId}]` : "";
4777
+ const ts = a.uploadedAt ?? "unknown";
4778
+ return `${a.name}${id} \u2014 ${a.ref} (${ts})`;
4170
4779
  }).join("\n");
4171
4780
  }
4781
+ var PROVIDER_CONFIGS = {
4782
+ browserstack: {
4783
+ name: "BrowserStack",
4784
+ apiBase: "https://api-cloud.browserstack.com",
4785
+ credsEnvNames: ["BROWSERSTACK_USERNAME", "BROWSERSTACK_ACCESS_KEY"],
4786
+ listPath: "/app-automate/recent_apps",
4787
+ supportsOrgWide: true,
4788
+ parseListResponse: (raw) => {
4789
+ const apps = Array.isArray(raw) ? raw : [];
4790
+ return apps.map((a) => ({
4791
+ name: `${a.app_name}`,
4792
+ ref: `${a.app_url}`,
4793
+ uploadedAt: `${a.uploaded_at}`,
4794
+ customId: a.custom_id
4795
+ }));
4796
+ },
4797
+ uploadPath: "/app-automate/upload",
4798
+ uploadField: "file",
4799
+ parseUploadResponse: (raw, fileName) => {
4800
+ const data = raw;
4801
+ return { appRef: data.app_url, appName: fileName };
4802
+ }
4803
+ },
4804
+ saucelabs: {
4805
+ name: "Sauce Labs",
4806
+ apiBase: "https://api.eu-central-1.saucelabs.com",
4807
+ // region overridden via param
4808
+ credsEnvNames: ["SAUCE_USERNAME", "SAUCE_ACCESS_KEY"],
4809
+ listPath: "/v1/storage/files",
4810
+ supportsOrgWide: false,
4811
+ parseListResponse: (raw) => {
4812
+ const data = raw;
4813
+ return (data.items ?? []).map((a) => ({
4814
+ name: a.name,
4815
+ ref: `storage:filename=${a.name}`,
4816
+ uploadedAt: a.uploadTimestamp ? new Date(a.uploadTimestamp).toISOString() : void 0,
4817
+ customId: a.customId
4818
+ }));
4819
+ },
4820
+ uploadPath: "/v1/storage/upload",
4821
+ uploadField: "payload",
4822
+ parseUploadResponse: (raw, fileName) => {
4823
+ const data = raw;
4824
+ const name = data.item?.name ?? fileName;
4825
+ return { appRef: `storage:filename=${name}`, appName: name };
4826
+ }
4827
+ },
4828
+ testmu: {
4829
+ name: "TestMu",
4830
+ apiBase: "https://manual-api.lambdatest.com",
4831
+ credsEnvNames: ["TESTMU_USERNAME", "TESTMU_ACCESS_KEY"],
4832
+ listPath: "/app/data",
4833
+ supportsOrgWide: false,
4834
+ parseListResponse: (raw) => {
4835
+ if (raw === null || raw === void 0 || typeof raw !== "object") return [];
4836
+ const data = raw;
4837
+ const apps = data.data ?? (Array.isArray(raw) ? raw : []);
4838
+ return apps.map((a) => ({
4839
+ name: a.name ?? a.app_name ?? "unknown",
4840
+ ref: a.app_id ? `lt://${a.app_id}` : `lt://${a.custom_id ?? "unknown"}`,
4841
+ uploadedAt: a.updated_at,
4842
+ customId: a.custom_id
4843
+ }));
4844
+ },
4845
+ uploadPath: "/app/upload/realDevice",
4846
+ uploadField: "appFile",
4847
+ parseUploadResponse: (raw, fileName) => {
4848
+ const data = raw;
4849
+ const ref = data.app_id ? `lt://${data.app_id}` : data.app_url ?? "unknown";
4850
+ return { appRef: ref, appName: data.name ?? fileName };
4851
+ }
4852
+ }
4853
+ };
4854
+ function getProviderConfig(provider, region) {
4855
+ const base = PROVIDER_CONFIGS[provider];
4856
+ if (!base) return { error: `Unknown provider: ${provider}` };
4857
+ const [userEnv, keyEnv] = base.credsEnvNames;
4858
+ const user = process.env[userEnv];
4859
+ const key = process.env[keyEnv];
4860
+ if (!user || !key) {
4861
+ const vars = base.credsEnvNames.join(" and ");
4862
+ return { error: `Missing credentials: set ${vars} environment variables.` };
4863
+ }
4864
+ const config = provider === "saucelabs" ? { ...base, apiBase: `https://api.${region ?? "eu-central-1"}.saucelabs.com` } : base;
4865
+ return { config, auth: basicAuth(user, key) };
4866
+ }
4172
4867
  var listAppsToolDefinition = {
4173
4868
  name: "list_apps",
4174
- description: "List apps uploaded to BrowserStack App Automate. Reads BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY from environment.",
4175
- annotations: { title: "List BrowserStack Apps", readOnlyHint: true, idempotentHint: true },
4869
+ description: "List apps uploaded to a cloud provider (BrowserStack App Automate, Sauce Labs App Storage, or TestMu Real Device Cloud). Reads provider-specific credentials from environment.",
4870
+ annotations: { title: "List Cloud Provider Apps", readOnlyHint: true, idempotentHint: true },
4176
4871
  inputSchema: {
4872
+ provider: z17.enum(["browserstack", "saucelabs", "testmu"]).describe("Cloud provider"),
4177
4873
  sortBy: z17.enum(["app_name", "uploaded_at"]).optional().default("uploaded_at").describe("Sort order for results"),
4178
- organizationWide: coerceBoolean.optional().default(false).describe("List apps uploaded by all users in the organization (uses recent_group_apps endpoint). Defaults to false (own uploads only)."),
4179
- limit: z17.number().int().min(1).optional().default(20).describe("Maximum number of apps to return (only applies when organizationWide is true, default 20)")
4874
+ organizationWide: coerceBoolean.optional().default(false).describe("(BrowserStack only) List apps uploaded by all users in the organization. Defaults to false (own uploads only)."),
4875
+ limit: z17.number().int().min(1).optional().default(20).describe("Maximum number of apps to return (only applies when organizationWide is true, default 20)"),
4876
+ region: z17.enum(["us-west-1", "eu-central-1", "apac-southeast-1"]).optional().default("eu-central-1").describe("Sauce Labs region (default: eu-central-1)")
4180
4877
  }
4181
4878
  };
4182
- var listAppsTool = async ({ sortBy = "uploaded_at", organizationWide = false, limit = 20 }) => {
4183
- const auth = getAuth();
4184
- if (!auth) {
4185
- return { isError: true, content: [{ type: "text", text: "Missing credentials: set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables." }] };
4879
+ var listAppsTool = async (args) => {
4880
+ const { provider, sortBy = "uploaded_at", organizationWide = false, limit = 20, region = "eu-central-1" } = args;
4881
+ const resolved = getProviderConfig(provider, region);
4882
+ if ("error" in resolved) {
4883
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
4186
4884
  }
4885
+ const { config, auth } = resolved;
4187
4886
  try {
4188
- let url = `${BS_API}/app-automate/${organizationWide ? "recent_group_apps" : "recent_apps"}`;
4189
- if (organizationWide && limit) url += `?limit=${limit}`;
4190
- const res = await fetch(url, {
4191
- headers: { Authorization: `Basic ${auth}` }
4192
- });
4193
- if (!res.ok) {
4194
- const body = await res.text();
4195
- return { isError: true, content: [{ type: "text", text: `BrowserStack API error ${res.status}: ${body}` }] };
4887
+ let url = `${config.apiBase}${config.listPath}`;
4888
+ if (config.supportsOrgWide && organizationWide) {
4889
+ url = `${config.apiBase}/app-automate/recent_group_apps?limit=${limit}`;
4890
+ }
4891
+ let apps = [];
4892
+ if (provider === "testmu") {
4893
+ const errors = [];
4894
+ for (const platform2 of ["android", "ios"]) {
4895
+ try {
4896
+ const res = await fetch(`${url}?type=${platform2}`, {
4897
+ headers: { Authorization: `Basic ${auth}` }
4898
+ });
4899
+ if (res.ok) {
4900
+ const raw = await res.json();
4901
+ apps.push(...config.parseListResponse(raw));
4902
+ } else {
4903
+ const body = await res.text();
4904
+ errors.push(`${config.name} API error ${res.status} (${platform2}): ${body}`);
4905
+ }
4906
+ } catch (e) {
4907
+ errors.push(String(e));
4908
+ }
4909
+ }
4910
+ if (apps.length === 0 && errors.length > 0) {
4911
+ const message = errors.length === 1 ? errors[0] : `All platform fetches failed:
4912
+ ${errors.map((e) => ` - ${e}`).join("\n")}`;
4913
+ return { isError: true, content: [{ type: "text", text: `Error listing apps: ${message}` }] };
4914
+ }
4915
+ } else {
4916
+ const res = await fetch(url, {
4917
+ headers: { Authorization: `Basic ${auth}` }
4918
+ });
4919
+ if (!res.ok) {
4920
+ const body = await res.text();
4921
+ return { isError: true, content: [{ type: "text", text: `${config.name} API error ${res.status}: ${body}` }] };
4922
+ }
4923
+ const raw = await res.json();
4924
+ apps = config.parseListResponse(raw);
4196
4925
  }
4197
- const raw = await res.json();
4198
- let apps = Array.isArray(raw) ? raw : [];
4199
- apps = sortBy === "app_name" ? apps.sort((a, b) => a.app_name.localeCompare(b.app_name)) : apps.sort((a, b) => new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
4926
+ apps = sortBy === "app_name" ? apps.sort((a, b) => a.name.localeCompare(b.name)) : apps.sort((a, b) => (b.uploadedAt ?? "").localeCompare(a.uploadedAt ?? ""));
4200
4927
  return { content: [{ type: "text", text: formatAppList(apps) }] };
4201
4928
  } catch (e) {
4202
4929
  return { isError: true, content: [{ type: "text", text: `Error listing apps: ${e}` }] };
@@ -4204,28 +4931,33 @@ var listAppsTool = async ({ sortBy = "uploaded_at", organizationWide = false, li
4204
4931
  };
4205
4932
  var uploadAppToolDefinition = {
4206
4933
  name: "upload_app",
4207
- description: "Upload a local .apk or .ipa to BrowserStack App Automate. Returns a bs:// URL for use in start_session.",
4208
- annotations: { title: "Upload App to BrowserStack", destructiveHint: false },
4934
+ description: "Upload a local .apk or .ipa to a cloud provider (BrowserStack, Sauce Labs, or TestMu). Returns the app URL for use in start_session.",
4935
+ annotations: { title: "Upload App to Cloud Provider", destructiveHint: false },
4209
4936
  inputSchema: {
4937
+ provider: z17.enum(["browserstack", "saucelabs", "testmu"]).describe("Cloud provider"),
4210
4938
  path: z17.string().describe("Absolute path to the .apk or .ipa file"),
4211
- customId: z17.string().optional().describe("Optional custom ID for the app (used to reference it later)")
4939
+ customId: z17.string().optional().describe("Optional custom ID for the app (used to reference it later)"),
4940
+ region: z17.enum(["us-west-1", "eu-central-1", "apac-southeast-1"]).optional().default("eu-central-1").describe("Sauce Labs region (default: eu-central-1)")
4212
4941
  }
4213
4942
  };
4214
- var uploadAppTool = async ({ path, customId }) => {
4215
- const auth = getAuth();
4216
- if (!auth) {
4217
- return { isError: true, content: [{ type: "text", text: "Missing credentials: set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables." }] };
4943
+ var uploadAppTool = async (args) => {
4944
+ const { provider, path, customId, region = "eu-central-1" } = args;
4945
+ const resolved = getProviderConfig(provider, region);
4946
+ if ("error" in resolved) {
4947
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
4218
4948
  }
4949
+ const { config, auth } = resolved;
4219
4950
  if (!existsSync2(path)) {
4220
4951
  return { isError: true, content: [{ type: "text", text: `File not found: ${path}` }] };
4221
4952
  }
4953
+ const fileName = path.split("/").pop() ?? "app";
4222
4954
  try {
4223
4955
  const form = new FormData();
4224
- const stream = createReadStream(path);
4225
- const fileName = path.split("/").pop() ?? "app";
4226
- form.append("file", new Blob([stream]), fileName);
4956
+ const fileBuffer = readFileSync(path);
4957
+ const fileBlob = new Blob([fileBuffer], { type: "application/octet-stream" });
4958
+ form.append(config.uploadField, fileBlob, fileName);
4227
4959
  if (customId) form.append("custom_id", customId);
4228
- const res = await fetch(`${BS_API}/app-automate/upload`, {
4960
+ const res = await fetch(`${config.apiBase}${config.uploadPath}`, {
4229
4961
  method: "POST",
4230
4962
  headers: { Authorization: `Basic ${auth}` },
4231
4963
  body: form
@@ -4234,13 +4966,14 @@ var uploadAppTool = async ({ path, customId }) => {
4234
4966
  const body = await res.text();
4235
4967
  return { isError: true, content: [{ type: "text", text: `Upload failed ${res.status}: ${body}` }] };
4236
4968
  }
4237
- const data = await res.json();
4238
- const customIdNote = data.custom_id ? `
4239
- Custom ID: ${data.custom_id}` : "";
4969
+ const raw = await res.json();
4970
+ const { appRef } = config.parseUploadResponse(raw, fileName);
4971
+ const customIdNote = customId ? `
4972
+ Custom ID: ${customId}` : "";
4240
4973
  return { content: [{ type: "text", text: `Upload successful.
4241
- App URL: ${data.app_url}${customIdNote}
4974
+ App: ${appRef}${customIdNote}
4242
4975
 
4243
- Use this URL as the "app" parameter in start_session with provider: "browserstack".` }] };
4976
+ Use "${appRef}" as the "app" parameter in start_session with provider: "${provider}".` }] };
4244
4977
  } catch (e) {
4245
4978
  return { isError: true, content: [{ type: "text", text: `Error uploading app: ${e}` }] };
4246
4979
  }
@@ -4431,6 +5164,8 @@ function createServer() {
4431
5164
  registerResource(sessionStepsResource);
4432
5165
  registerResource(sessionCodeResource);
4433
5166
  registerResource(browserstackLocalBinaryResource);
5167
+ registerResource(saucelabsLocalBinaryResource);
5168
+ registerResource(testmuLocalBinaryResource);
4434
5169
  registerResource(capabilitiesResource);
4435
5170
  registerResource(elementsResource);
4436
5171
  registerResource(accessibilityResource);