@wdio/mcp 3.5.0 → 3.5.2

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.4.4",
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",
@@ -97,6 +97,7 @@ var package_default = {
97
97
  "@xmldom/xmldom": "^0.9.10",
98
98
  "browserstack-local": "^1.5.12",
99
99
  "puppeteer-core": "^24.40.0",
100
+ saucelabs: "^9.0.2",
100
101
  sharp: "^0.34.5",
101
102
  webdriverio: "^9.27.0",
102
103
  xpath: "^0.0.34",
@@ -2130,9 +2131,7 @@ init_state();
2130
2131
  import sharp from "sharp";
2131
2132
 
2132
2133
  // src/trace/state.ts
2133
- import { createRequire } from "module";
2134
- var require2 = createRequire(import.meta.url);
2135
- var { version: LIBRARY_VERSION } = require2("../../package.json");
2134
+ var libraryVersion = package_default.version;
2136
2135
  var traceSessions = /* @__PURE__ */ new Map();
2137
2136
  function createTraceSession(sessionId, browserName, viewport, title, sessionType = "browser") {
2138
2137
  const prefix = sessionId.slice(0, 8);
@@ -2156,7 +2155,7 @@ function createTraceSession(sessionId, browserName, viewport, title, sessionType
2156
2155
  type: "context-options",
2157
2156
  origin: "library",
2158
2157
  libraryName: "@wdio/mcp",
2159
- libraryVersion: LIBRARY_VERSION,
2158
+ libraryVersion,
2160
2159
  browserName,
2161
2160
  platform: process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux",
2162
2161
  wallTime: session.startWallTime,
@@ -2429,6 +2428,71 @@ var browserstackLocalBinaryResource = {
2429
2428
  }
2430
2429
  };
2431
2430
 
2431
+ // src/resources/saucelabs-local.resource.ts
2432
+ function getLocalBinaryInfo2() {
2433
+ const platform2 = process.platform;
2434
+ const arch = process.arch;
2435
+ if (platform2 === "darwin") {
2436
+ return {
2437
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-osx.zip",
2438
+ platform: "macOS",
2439
+ arch: arch === "arm64" ? "Apple Silicon" : "Intel x64",
2440
+ binaryName: "sc"
2441
+ };
2442
+ }
2443
+ if (platform2 === "win32") {
2444
+ return {
2445
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-win32.zip",
2446
+ platform: "Windows",
2447
+ arch: "x86/x64",
2448
+ binaryName: "sc.exe"
2449
+ };
2450
+ }
2451
+ return {
2452
+ downloadUrl: "https://saucelabs.com/downloads/sc-4.9.2-linux.tar.gz",
2453
+ platform: "Linux",
2454
+ arch: arch === "arm64" ? "ARM64" : "x64",
2455
+ binaryName: "sc"
2456
+ };
2457
+ }
2458
+ var saucelabsLocalBinaryResource = {
2459
+ name: "saucelabs-local-binary",
2460
+ uri: "wdio://saucelabs/local-binary",
2461
+ 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.",
2462
+ handler: async () => {
2463
+ const info = getLocalBinaryInfo2();
2464
+ const username = process.env.SAUCE_USERNAME ?? "<SAUCE_USERNAME>";
2465
+ const accessKey = process.env.SAUCE_ACCESS_KEY ?? "<SAUCE_ACCESS_KEY>";
2466
+ const region = process.env.SAUCE_REGION ?? "eu-central-1";
2467
+ const content = {
2468
+ 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.",
2469
+ platform: info.platform,
2470
+ arch: info.arch,
2471
+ downloadUrl: info.downloadUrl,
2472
+ ...info.note ? { note: info.note } : {},
2473
+ setup: [
2474
+ `1. Download: curl -O ${info.downloadUrl}`,
2475
+ `2. Unzip: ${info.downloadUrl.endsWith(".tar.gz") ? `tar -xzf ${info.downloadUrl.split("/").pop()}` : `unzip ${info.downloadUrl.split("/").pop()}`}`,
2476
+ `3. Make executable (macOS/Linux): chmod +x ${info.binaryName}`,
2477
+ `4. Start daemon: ./${info.binaryName} -u ${username} -k ${accessKey} --region ${region}`
2478
+ ],
2479
+ commands: {
2480
+ start: `./${info.binaryName} -u ${username} -k ${accessKey} --region ${region}`,
2481
+ stop: `./${info.binaryName} --stop`,
2482
+ status: `./${info.binaryName} --status`
2483
+ },
2484
+ afterDaemonIsRunning: "Call start_session with tunnel: 'external' and tunnelName matching this daemon to route traffic through it."
2485
+ };
2486
+ return {
2487
+ contents: [{
2488
+ uri: "wdio://saucelabs/local-binary",
2489
+ mimeType: "application/json",
2490
+ text: JSON.stringify(content, null, 2)
2491
+ }]
2492
+ };
2493
+ }
2494
+ };
2495
+
2432
2496
  // src/resources/sessions.resource.ts
2433
2497
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2434
2498
 
@@ -2455,6 +2519,7 @@ function generateStep(step, history) {
2455
2519
  case "start_session": {
2456
2520
  const platform2 = p.platform;
2457
2521
  const isBrowserStack = "bstack:options" in history.capabilities;
2522
+ const isSauceLabs = "sauce:options" in history.capabilities;
2458
2523
  const capJson = indentJson(history.capabilities).split("\n").map((line, i) => i > 0 ? ` ${line}` : line).join("\n");
2459
2524
  if (isBrowserStack) {
2460
2525
  const nav = platform2 === "browser" && p.navigationUrl ? `
@@ -2471,6 +2536,22 @@ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2471
2536
  `});${nav}`
2472
2537
  ].join("\n");
2473
2538
  }
2539
+ if (isSauceLabs) {
2540
+ const region = history.capabilities["sauce:options"]?.region ?? "eu-central-1";
2541
+ const nav = platform2 === "browser" && p.navigationUrl ? `
2542
+ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2543
+ return [
2544
+ "const browser = await remote({",
2545
+ " protocol: 'https',",
2546
+ ` hostname: 'ondemand.${region}.saucelabs.com',`,
2547
+ " port: 443,",
2548
+ " path: '/wd/hub',",
2549
+ " user: process.env.SAUCE_USERNAME,",
2550
+ " key: process.env.SAUCE_ACCESS_KEY,",
2551
+ ` capabilities: ${capJson}`,
2552
+ `});${nav}`
2553
+ ].join("\n");
2554
+ }
2474
2555
  if (platform2 === "browser") {
2475
2556
  const nav = p.navigationUrl ? `
2476
2557
  await browser.url('${escapeStr(p.navigationUrl)}');` : "";
@@ -2533,8 +2614,10 @@ function bsStatusUpdateLines(sessionType) {
2533
2614
  }
2534
2615
  function generateCode(history) {
2535
2616
  const bstackOptions = history.capabilities["bstack:options"];
2617
+ const sauceOptions = history.capabilities["sauce:options"];
2536
2618
  const isBrowserStack = bstackOptions !== void 0;
2537
- const usesBrowserstackLocal = bstackOptions?.local === true;
2619
+ const isSauceLabs = sauceOptions !== void 0;
2620
+ const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true : sauceOptions?.tunnelName !== void 0;
2538
2621
  const steps = history.steps.map((step) => generateStep(step, history)).join("\n").split("\n").map((line) => ` ${line}`).join("\n");
2539
2622
  if (isBrowserStack) {
2540
2623
  const bsSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
@@ -2545,7 +2628,7 @@ function generateCode(history) {
2545
2628
  ${statusUpdate}
2546
2629
  await browser.deleteSession();
2547
2630
  }`;
2548
- if (usesBrowserstackLocal) {
2631
+ if (usesLocalTunnel) {
2549
2632
  const tunnelSetup = [
2550
2633
  "",
2551
2634
  "const tunnel = new BrowserstackTunnel();",
@@ -2581,6 +2664,64 @@ ${statusUpdate}
2581
2664
  "}"
2582
2665
  ].join("\n");
2583
2666
  }
2667
+ if (isSauceLabs) {
2668
+ const slSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
2669
+ const preamble = "let browser;\nlet slStatus = 'passed';\nlet slReason;";
2670
+ const catchBlock = "} catch (e) {\n slStatus = 'failed';\n slReason = String(e);\n throw e;";
2671
+ const slRegion = sauceOptions?.region ?? "eu-central-1";
2672
+ const statusUpdate = [
2673
+ " const slAuth = Buffer.from(`${process.env.SAUCE_USERNAME}:${process.env.SAUCE_ACCESS_KEY}`).toString('base64');",
2674
+ ` await fetch(\`https://api.${slRegion}.saucelabs.com/rest/v1/\${process.env.SAUCE_USERNAME}/jobs/\` + browser.sessionId, {`,
2675
+ " method: 'PUT',",
2676
+ " headers: { Authorization: 'Basic ' + slAuth, 'Content-Type': 'application/json' },",
2677
+ " body: JSON.stringify({ passed: slStatus === 'passed' })",
2678
+ " });"
2679
+ ].join("\n");
2680
+ const finallyLines = [
2681
+ " if (browser) {",
2682
+ statusUpdate,
2683
+ " await browser.deleteSession();",
2684
+ " }"
2685
+ ];
2686
+ if (usesLocalTunnel) {
2687
+ const tunnelSetup = [
2688
+ "",
2689
+ "import SauceLabs from 'saucelabs';",
2690
+ "",
2691
+ "const sl = new SauceLabs({",
2692
+ " user: process.env.SAUCE_USERNAME,",
2693
+ " key: process.env.SAUCE_ACCESS_KEY,",
2694
+ ` region: '${slRegion}',`,
2695
+ "});",
2696
+ "const sc = await sl.startSauceConnect({ tunnelName: 'wdio-mcp-tunnel' });",
2697
+ "const stopTunnel = () => sc.close();",
2698
+ ""
2699
+ ].join("\n");
2700
+ return [
2701
+ "import { remote } from 'webdriverio';",
2702
+ tunnelSetup,
2703
+ preamble,
2704
+ "try {",
2705
+ slSteps,
2706
+ catchBlock,
2707
+ "} finally {",
2708
+ ...finallyLines,
2709
+ " await stopTunnel();",
2710
+ "}"
2711
+ ].join("\n");
2712
+ }
2713
+ return [
2714
+ "import { remote } from 'webdriverio';",
2715
+ "",
2716
+ preamble,
2717
+ "try {",
2718
+ slSteps,
2719
+ catchBlock,
2720
+ "} finally {",
2721
+ ...finallyLines,
2722
+ "}"
2723
+ ].join("\n");
2724
+ }
2584
2725
  return `import { remote } from 'webdriverio';
2585
2726
 
2586
2727
  try {
@@ -3278,7 +3419,7 @@ import { z as z14 } from "zod";
3278
3419
  // src/session/lifecycle.ts
3279
3420
  init_state();
3280
3421
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3281
- import { join as join3 } from "path";
3422
+ import { join as join4 } from "path";
3282
3423
 
3283
3424
  // src/providers/local-browser.provider.ts
3284
3425
  var LocalBrowserProvider = class {
@@ -3533,6 +3674,13 @@ import { promisify } from "util";
3533
3674
  import { tmpdir as tmpdir2 } from "os";
3534
3675
  import { join as join2 } from "path";
3535
3676
  import { Local as BrowserstackTunnel } from "browserstack-local";
3677
+
3678
+ // src/utils/auth.ts
3679
+ function basicAuth(user, key) {
3680
+ return Buffer.from(`${user}:${key}`).toString("base64");
3681
+ }
3682
+
3683
+ // src/providers/cloud/browserstack.provider.ts
3536
3684
  var BrowserStackProvider = class {
3537
3685
  name = "browserstack";
3538
3686
  getConnectionConfig(options) {
@@ -3550,7 +3698,7 @@ var BrowserStackProvider = class {
3550
3698
  buildCapabilities(options) {
3551
3699
  const platform2 = options.platform;
3552
3700
  const userCapabilities = options.capabilities ?? {};
3553
- const browserstackLocal = options.browserstackLocal;
3701
+ const browserstackLocal = options.tunnel ?? options.browserstackLocal;
3554
3702
  if (platform2 === "browser") {
3555
3703
  const bstackOptions2 = {
3556
3704
  browserVersion: options.browserVersion ?? "latest"
@@ -3628,7 +3776,7 @@ var BrowserStackProvider = class {
3628
3776
  const key = process.env.BROWSERSTACK_ACCESS_KEY;
3629
3777
  if (!user || !key) return;
3630
3778
  const baseUrl = sessionType === "browser" ? "https://api.browserstack.com/automate/sessions" : "https://api-cloud.browserstack.com/app-automate/sessions";
3631
- const auth = Buffer.from(`${user}:${key}`).toString("base64");
3779
+ const auth = basicAuth(user, key);
3632
3780
  const body = { status: result.status, ...result.reason ? { reason: result.reason } : {} };
3633
3781
  await fetch(`${baseUrl}/${sessionId}.json`, {
3634
3782
  method: "PUT",
@@ -3639,11 +3787,153 @@ var BrowserStackProvider = class {
3639
3787
  };
3640
3788
  var browserStackProvider = new BrowserStackProvider();
3641
3789
 
3790
+ // src/providers/cloud/saucelabs.provider.ts
3791
+ import { tmpdir as tmpdir3 } from "os";
3792
+ import { join as join3 } from "path";
3793
+ var SauceLabsProvider = class {
3794
+ name = "saucelabs";
3795
+ resolveRegion(options) {
3796
+ return options.region ?? "eu-central-1";
3797
+ }
3798
+ getConnectionConfig(options) {
3799
+ const region = this.resolveRegion(options);
3800
+ return {
3801
+ protocol: "https",
3802
+ hostname: `ondemand.${region}.saucelabs.com`,
3803
+ port: 443,
3804
+ path: "/wd/hub",
3805
+ user: process.env.SAUCE_USERNAME,
3806
+ key: process.env.SAUCE_ACCESS_KEY
3807
+ };
3808
+ }
3809
+ buildCapabilities(options) {
3810
+ const platform2 = options.platform;
3811
+ const region = this.resolveRegion(options);
3812
+ const userCapabilities = options.capabilities ?? {};
3813
+ const saucelabsLocal = options.tunnel ?? options.saucelabsLocal;
3814
+ const reporting = options.reporting;
3815
+ const sauceOptions = { region };
3816
+ if (reporting?.build) sauceOptions.build = reporting.build;
3817
+ if (reporting?.session) sauceOptions.name = reporting.session;
3818
+ else if (reporting?.project) sauceOptions.name = reporting.project;
3819
+ if (saucelabsLocal) {
3820
+ sauceOptions.tunnelName = options.tunnelName;
3821
+ }
3822
+ if (platform2 === "browser") {
3823
+ return {
3824
+ browserName: options.browser ?? "chrome",
3825
+ browserVersion: options.browserVersion ?? "latest",
3826
+ platformName: options.os ?? "Linux",
3827
+ "sauce:options": sauceOptions,
3828
+ ...userCapabilities
3829
+ };
3830
+ }
3831
+ const mobileBrowser = options.browser;
3832
+ if (mobileBrowser) {
3833
+ sauceOptions.appiumVersion = "2.11.0";
3834
+ if (options.deviceOrientation) sauceOptions.deviceOrientation = options.deviceOrientation;
3835
+ const caps = {
3836
+ platformName: platform2,
3837
+ browserName: mobileBrowser,
3838
+ "appium:deviceName": options.deviceName,
3839
+ "appium:platformVersion": options.platformVersion,
3840
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
3841
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
3842
+ "sauce:options": sauceOptions
3843
+ };
3844
+ return { ...caps, ...userCapabilities };
3845
+ }
3846
+ sauceOptions.appiumVersion = "latest";
3847
+ const autoAcceptAlerts = options.autoAcceptAlerts;
3848
+ const autoDismissAlerts = options.autoDismissAlerts;
3849
+ return {
3850
+ platformName: platform2,
3851
+ "appium:app": options.app,
3852
+ "appium:deviceName": options.deviceName,
3853
+ "appium:platformVersion": options.platformVersion,
3854
+ "appium:automationName": options.automationName ?? (platform2 === "ios" ? "XCUITest" : "UiAutomator2"),
3855
+ "appium:autoGrantPermissions": options.autoGrantPermissions ?? true,
3856
+ "appium:autoAcceptAlerts": autoDismissAlerts ? void 0 : autoAcceptAlerts ?? true,
3857
+ "appium:autoDismissAlerts": autoDismissAlerts,
3858
+ "appium:newCommandTimeout": options.newCommandTimeout ?? 300,
3859
+ "sauce:options": sauceOptions,
3860
+ ...userCapabilities
3861
+ };
3862
+ }
3863
+ getSessionType(options) {
3864
+ const platform2 = options.platform;
3865
+ if (platform2 === "browser") return "browser";
3866
+ return platform2;
3867
+ }
3868
+ shouldAutoDetach(_options) {
3869
+ return false;
3870
+ }
3871
+ async startTunnel(options) {
3872
+ const region = this.resolveRegion(options);
3873
+ const tunnelName = options.tunnelName ?? `wdio-mcp-${Date.now()}`;
3874
+ const logFile = join3(tmpdir3(), "sauce-connect.log");
3875
+ console.error(`[SauceLabs] Starting tunnel "${tunnelName}" (region: ${region})`);
3876
+ try {
3877
+ const { default: SauceLabs } = await import("saucelabs");
3878
+ const api = new SauceLabs({ user: process.env.SAUCE_USERNAME ?? "", key: process.env.SAUCE_ACCESS_KEY ?? "", region });
3879
+ return api.startSauceConnect({
3880
+ tunnelName,
3881
+ logFile,
3882
+ logger: (msg) => console.error(`[SauceConnect] ${msg}`)
3883
+ });
3884
+ } catch (e) {
3885
+ const msg = (e !== null && typeof e === "object" ? e.message : void 0) ?? String(e);
3886
+ if (msg.includes("already running") || msg.includes("another instance")) {
3887
+ console.error("[SauceLabs] Tunnel already running \u2014 reusing existing tunnel");
3888
+ return null;
3889
+ }
3890
+ throw e;
3891
+ }
3892
+ }
3893
+ async onSessionClose(sessionId, _sessionType, result, _tunnelHandle, _browser, region) {
3894
+ const effectiveRegion = region ?? "eu-central-1";
3895
+ const user = process.env.SAUCE_USERNAME;
3896
+ const key = process.env.SAUCE_ACCESS_KEY;
3897
+ if (user && key) {
3898
+ try {
3899
+ const auth = basicAuth(user, key);
3900
+ const body = { passed: result.status === "passed" };
3901
+ const apiUrl = `https://api.${effectiveRegion}.saucelabs.com/rest/v1/${user}/jobs/${sessionId}`;
3902
+ console.error(`[SauceLabs] Setting job status for ${sessionId}: ${result.status}`);
3903
+ await fetch(apiUrl, {
3904
+ method: "PUT",
3905
+ headers: {
3906
+ Authorization: `Basic ${auth}`,
3907
+ "Content-Type": "application/json"
3908
+ },
3909
+ body: JSON.stringify(body)
3910
+ });
3911
+ console.error("[SauceLabs] Job result set successfully via REST API");
3912
+ } catch (e) {
3913
+ console.error("[SauceLabs] Failed to set job result via REST API:", e);
3914
+ }
3915
+ }
3916
+ }
3917
+ async stopTunnel(tunnelHandle) {
3918
+ if (tunnelHandle) {
3919
+ const sc = tunnelHandle;
3920
+ await sc.close();
3921
+ }
3922
+ }
3923
+ };
3924
+ var sauceLabsProvider = new SauceLabsProvider();
3925
+
3642
3926
  // src/providers/registry.ts
3643
- function getProvider(providerName, platform2) {
3644
- if (providerName === "browserstack") return browserStackProvider;
3927
+ var providers = /* @__PURE__ */ new Map([
3928
+ ["browserstack", browserStackProvider],
3929
+ ["saucelabs", sauceLabsProvider]
3930
+ ]);
3931
+ function getDefaultProvider(platform2) {
3645
3932
  return platform2 === "browser" ? localBrowserProvider : localAppiumProvider;
3646
3933
  }
3934
+ function getProvider(providerName, platform2) {
3935
+ return providers.get(providerName) ?? getDefaultProvider(platform2);
3936
+ }
3647
3937
 
3648
3938
  // src/trace/zip-writer.ts
3649
3939
  import yazl from "yazl";
@@ -3673,10 +3963,10 @@ async function finalizeTrace(sessionId, browser) {
3673
3963
  if (!traceSession) return;
3674
3964
  try {
3675
3965
  await traceSession.screenshotChain;
3676
- const traceDir = join3(process.cwd(), ".trace");
3966
+ const traceDir = join4(process.cwd(), ".trace");
3677
3967
  mkdirSync2(traceDir, { recursive: true });
3678
3968
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3679
- const outPath = join3(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
3969
+ const outPath = join4(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
3680
3970
  const zipBuffer = await buildTraceZip(traceSession);
3681
3971
  writeFileSync2(outPath, zipBuffer);
3682
3972
  console.error(`[TRACE] Saved to ${outPath}`);
@@ -3728,11 +4018,16 @@ function registerSession(sessionId, browser, metadata, historyEntry) {
3728
4018
  if (oldMetadata?.provider) {
3729
4019
  const oldHistory = state2.sessionHistory.get(oldSessionId);
3730
4020
  const provider = getProvider(oldMetadata.provider, oldMetadata.type);
3731
- await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {
4021
+ await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle, oldBrowser, oldMetadata.region).catch(() => {
3732
4022
  });
3733
4023
  }
3734
4024
  await oldBrowser.deleteSession().catch(() => {
3735
4025
  });
4026
+ if (oldMetadata?.provider && oldMetadata?.tunnelHandle) {
4027
+ const provider = getProvider(oldMetadata.provider, oldMetadata.type);
4028
+ await provider.stopTunnel?.(oldMetadata.tunnelHandle).catch(() => {
4029
+ });
4030
+ }
3736
4031
  })();
3737
4032
  state2.browsers.delete(oldSessionId);
3738
4033
  state2.sessionMetadata.delete(oldSessionId);
@@ -3755,12 +4050,20 @@ async function closeSession(sessionId, detach, isAttached, force) {
3755
4050
  if (metadata?.provider) {
3756
4051
  try {
3757
4052
  const provider = getProvider(metadata.provider, metadata.type);
3758
- await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
4053
+ await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle, browser, metadata.region);
3759
4054
  } catch (e) {
3760
4055
  console.error("[WARN] Failed to run provider onSessionClose:", e);
3761
4056
  }
3762
4057
  }
3763
4058
  await browser.deleteSession();
4059
+ if (metadata?.provider && metadata?.tunnelHandle) {
4060
+ try {
4061
+ const provider = getProvider(metadata.provider, metadata.type);
4062
+ await provider.stopTunnel?.(metadata.tunnelHandle);
4063
+ } catch (e) {
4064
+ console.error("[WARN] Failed to stop tunnel:", e);
4065
+ }
4066
+ }
3764
4067
  }
3765
4068
  state2.browsers.delete(sessionId);
3766
4069
  state2.sessionMetadata.delete(sessionId);
@@ -3778,18 +4081,18 @@ var startSessionToolDefinition = {
3778
4081
  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.',
3779
4082
  annotations: { title: "Start Session", destructiveHint: false },
3780
4083
  inputSchema: {
3781
- provider: z14.enum(["local", "browserstack"]).optional().default("local").describe("Session provider (default: local)"),
4084
+ provider: z14.enum(["local", "browserstack", "saucelabs"]).optional().default("local").describe("Session provider (default: local)"),
3782
4085
  platform: platformEnum.describe("Session platform type"),
3783
4086
  browser: browserEnum.optional().describe("Browser to launch (required for browser platform)"),
3784
- browserVersion: z14.string().optional().describe("Browser version (BrowserStack only, default: latest)"),
3785
- os: z14.string().optional().describe('Operating system (BrowserStack browser only, e.g. "Windows", "OS X")'),
3786
- osVersion: z14.string().optional().describe('OS version (BrowserStack browser only, e.g. "11", "Sequoia")'),
3787
- app: z14.string().optional().describe("BrowserStack app URL (bs://...) or custom_id for mobile sessions"),
4087
+ browserVersion: z14.string().optional().describe("Browser version (cloud providers only, default: latest)"),
4088
+ os: z14.string().optional().describe('Operating system (cloud providers only, e.g. "Windows", "OS X")'),
4089
+ osVersion: z14.string().optional().describe('OS version (cloud providers only, e.g. "11", "Sequoia")'),
4090
+ app: z14.string().optional().describe("App URL (bs://...) for BrowserStack or storage:filename= for Sauce Labs mobile sessions"),
3788
4091
  reporting: z14.object({
3789
4092
  project: z14.string().optional(),
3790
4093
  build: z14.string().optional(),
3791
4094
  session: z14.string().optional()
3792
- }).optional().describe("BrowserStack reporting labels (project, build, session)"),
4095
+ }).optional().describe("Cloud provider reporting labels (project, build, session)"),
3793
4096
  headless: coerceBoolean.optional().default(true).describe("Run browser in headless mode (default: true)"),
3794
4097
  windowWidth: z14.number().min(400).max(3840).optional().default(1920).describe("Browser window width"),
3795
4098
  windowHeight: z14.number().min(400).max(2160).optional().default(1080).describe("Browser window height"),
@@ -3817,7 +4120,11 @@ var startSessionToolDefinition = {
3817
4120
  path: z14.string().optional(),
3818
4121
  protocol: z14.string().optional()
3819
4122
  }).optional().describe("Appium server connection (local provider only)"),
3820
- 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.'),
4123
+ 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".'),
4124
+ 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.'),
4125
+ 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.'),
4126
+ browserstackLocal: z14.union([z14.literal("external"), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable BrowserStack Local tunnel routing.'),
4127
+ saucelabsLocal: z14.union([z14.literal("external"), coerceBoolean]).optional().describe('Deprecated: use "tunnel" instead. Enable Sauce Connect tunnel routing.'),
3821
4128
  navigationUrl: z14.string().optional().describe("URL to navigate to after starting"),
3822
4129
  capabilities: z14.record(z14.string(), z14.unknown()).optional().describe("Additional capabilities to merge")
3823
4130
  }
@@ -3896,15 +4203,19 @@ async function startBrowserSession(args) {
3896
4203
  const effectiveHeadless = headless && headlessSupported;
3897
4204
  const provider = getProvider(args.provider ?? "local", "browser");
3898
4205
  const connectionConfig = provider.getConnectionConfig(args);
4206
+ const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? false;
4207
+ const tunnelEnabled = effectiveTunnel === true;
4208
+ const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName;
3899
4209
  const mergedCapabilities = provider.buildCapabilities({
3900
4210
  ...args,
3901
4211
  browser,
3902
4212
  headless,
3903
4213
  windowWidth,
3904
4214
  windowHeight,
3905
- capabilities: userCapabilities
4215
+ capabilities: userCapabilities,
4216
+ tunnelName
3906
4217
  });
3907
- const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
4218
+ const tunnelHandle = tunnelEnabled ? await provider.startTunnel?.({ ...args, tunnelName }) : void 0;
3908
4219
  const wdioBrowser = await remote({ ...connectionConfig, capabilities: mergedCapabilities });
3909
4220
  const { sessionId } = wdioBrowser;
3910
4221
  const sessionMetadata = {
@@ -3912,6 +4223,8 @@ async function startBrowserSession(args) {
3912
4223
  capabilities: mergedCapabilities,
3913
4224
  isAttached: false,
3914
4225
  provider: args.provider ?? "local",
4226
+ region: args.region,
4227
+ tunnelName,
3915
4228
  tunnelHandle,
3916
4229
  trace: args.trace ?? false
3917
4230
  };
@@ -3960,8 +4273,11 @@ async function startMobileSession(args) {
3960
4273
  }
3961
4274
  const provider = getProvider(args.provider ?? "local", args.platform);
3962
4275
  const serverConfig = provider.getConnectionConfig(args);
3963
- const mergedCapabilities = provider.buildCapabilities(args);
3964
- const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
4276
+ const effectiveTunnel = args.tunnel ?? args.browserstackLocal ?? args.saucelabsLocal ?? false;
4277
+ const tunnelEnabled = effectiveTunnel === true;
4278
+ const tunnelName = tunnelEnabled && !args.tunnelName ? `wdio-mcp-${Date.now()}` : args.tunnelName;
4279
+ const mergedCapabilities = provider.buildCapabilities({ ...args, tunnelName });
4280
+ const tunnelHandle = tunnelEnabled ? await provider.startTunnel?.({ ...args, tunnelName }) : void 0;
3965
4281
  const browser = await remote({ ...serverConfig, capabilities: mergedCapabilities });
3966
4282
  const { sessionId } = browser;
3967
4283
  const shouldAutoDetach = provider.shouldAutoDetach(args);
@@ -3971,6 +4287,8 @@ async function startMobileSession(args) {
3971
4287
  capabilities: mergedCapabilities,
3972
4288
  isAttached: shouldAutoDetach,
3973
4289
  provider: args.provider ?? "local",
4290
+ region: args.region,
4291
+ tunnelName,
3974
4292
  tunnelHandle,
3975
4293
  trace: args.trace ?? false
3976
4294
  };
@@ -4154,51 +4472,112 @@ var switchFrameTool = async ({
4154
4472
  }
4155
4473
  };
4156
4474
 
4157
- // src/tools/browserstack.tool.ts
4158
- import { existsSync as existsSync2, createReadStream } from "fs";
4475
+ // src/tools/cloud-provider.tool.ts
4476
+ import { existsSync as existsSync2, readFileSync } from "fs";
4159
4477
  import { z as z17 } from "zod";
4160
- var BS_API = "https://api-cloud.browserstack.com";
4161
- function getAuth() {
4162
- const user = process.env.BROWSERSTACK_USERNAME;
4163
- const key = process.env.BROWSERSTACK_ACCESS_KEY;
4164
- if (!user || !key) return null;
4165
- return Buffer.from(`${user}:${key}`).toString("base64");
4166
- }
4167
4478
  function formatAppList(apps) {
4168
4479
  if (apps.length === 0) return "No apps found.";
4169
4480
  return apps.map((a) => {
4170
- const id = a.custom_id ? ` [${a.custom_id}]` : "";
4171
- return `${a.app_name} v${a.app_version}${id} \u2014 ${a.app_url} (${a.uploaded_at})`;
4481
+ const id = a.customId ? ` [${a.customId}]` : "";
4482
+ const ts = a.uploadedAt ?? "unknown";
4483
+ return `${a.name}${id} \u2014 ${a.ref} (${ts})`;
4172
4484
  }).join("\n");
4173
4485
  }
4486
+ var PROVIDER_CONFIGS = {
4487
+ browserstack: {
4488
+ name: "BrowserStack",
4489
+ apiBase: "https://api-cloud.browserstack.com",
4490
+ credsEnvNames: ["BROWSERSTACK_USERNAME", "BROWSERSTACK_ACCESS_KEY"],
4491
+ listPath: "/app-automate/recent_apps",
4492
+ supportsOrgWide: true,
4493
+ parseListResponse: (raw) => {
4494
+ const apps = Array.isArray(raw) ? raw : [];
4495
+ return apps.map((a) => ({
4496
+ name: `${a.app_name}`,
4497
+ ref: `${a.app_url}`,
4498
+ uploadedAt: `${a.uploaded_at}`,
4499
+ customId: a.custom_id
4500
+ }));
4501
+ },
4502
+ uploadPath: "/app-automate/upload",
4503
+ uploadField: "file",
4504
+ parseUploadResponse: (raw, fileName) => {
4505
+ const data = raw;
4506
+ return { appRef: data.app_url, appName: fileName };
4507
+ }
4508
+ },
4509
+ saucelabs: {
4510
+ name: "Sauce Labs",
4511
+ apiBase: "https://api.eu-central-1.saucelabs.com",
4512
+ // region overridden via param
4513
+ credsEnvNames: ["SAUCE_USERNAME", "SAUCE_ACCESS_KEY"],
4514
+ listPath: "/v1/storage/files",
4515
+ supportsOrgWide: false,
4516
+ parseListResponse: (raw) => {
4517
+ const data = raw;
4518
+ return (data.items ?? []).map((a) => ({
4519
+ name: a.name,
4520
+ ref: `storage:filename=${a.name}`,
4521
+ uploadedAt: a.uploadTimestamp ? new Date(a.uploadTimestamp).toISOString() : void 0,
4522
+ customId: a.customId
4523
+ }));
4524
+ },
4525
+ uploadPath: "/v1/storage/upload",
4526
+ uploadField: "payload",
4527
+ parseUploadResponse: (raw, fileName) => {
4528
+ const data = raw;
4529
+ const name = data.item?.name ?? fileName;
4530
+ return { appRef: `storage:filename=${name}`, appName: name };
4531
+ }
4532
+ }
4533
+ };
4534
+ function getProviderConfig(provider, region) {
4535
+ const base = PROVIDER_CONFIGS[provider];
4536
+ if (!base) return { error: `Unknown provider: ${provider}` };
4537
+ const [userEnv, keyEnv] = base.credsEnvNames;
4538
+ const user = process.env[userEnv];
4539
+ const key = process.env[keyEnv];
4540
+ if (!user || !key) {
4541
+ const vars = base.credsEnvNames.join(" and ");
4542
+ return { error: `Missing credentials: set ${vars} environment variables.` };
4543
+ }
4544
+ const config = provider === "saucelabs" ? { ...base, apiBase: `https://api.${region ?? "eu-central-1"}.saucelabs.com` } : base;
4545
+ return { config, auth: basicAuth(user, key) };
4546
+ }
4174
4547
  var listAppsToolDefinition = {
4175
4548
  name: "list_apps",
4176
- description: "List apps uploaded to BrowserStack App Automate. Reads BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY from environment.",
4177
- annotations: { title: "List BrowserStack Apps", readOnlyHint: true, idempotentHint: true },
4549
+ description: "List apps uploaded to a cloud provider (BrowserStack App Automate or Sauce Labs App Storage). Reads provider-specific credentials from environment.",
4550
+ annotations: { title: "List Cloud Provider Apps", readOnlyHint: true, idempotentHint: true },
4178
4551
  inputSchema: {
4552
+ provider: z17.enum(["browserstack", "saucelabs"]).describe("Cloud provider"),
4179
4553
  sortBy: z17.enum(["app_name", "uploaded_at"]).optional().default("uploaded_at").describe("Sort order for results"),
4180
- 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)."),
4181
- limit: z17.number().int().min(1).optional().default(20).describe("Maximum number of apps to return (only applies when organizationWide is true, default 20)")
4554
+ organizationWide: coerceBoolean.optional().default(false).describe("(BrowserStack only) List apps uploaded by all users in the organization. Defaults to false (own uploads only)."),
4555
+ limit: z17.number().int().min(1).optional().default(20).describe("Maximum number of apps to return (only applies when organizationWide is true, default 20)"),
4556
+ 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)")
4182
4557
  }
4183
4558
  };
4184
- var listAppsTool = async ({ sortBy = "uploaded_at", organizationWide = false, limit = 20 }) => {
4185
- const auth = getAuth();
4186
- if (!auth) {
4187
- return { isError: true, content: [{ type: "text", text: "Missing credentials: set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables." }] };
4559
+ var listAppsTool = async (args) => {
4560
+ const { provider, sortBy = "uploaded_at", organizationWide = false, limit = 20, region = "eu-central-1" } = args;
4561
+ const resolved = getProviderConfig(provider, region);
4562
+ if ("error" in resolved) {
4563
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
4188
4564
  }
4565
+ const { config, auth } = resolved;
4189
4566
  try {
4190
- let url = `${BS_API}/app-automate/${organizationWide ? "recent_group_apps" : "recent_apps"}`;
4191
- if (organizationWide && limit) url += `?limit=${limit}`;
4567
+ let url = `${config.apiBase}${config.listPath}`;
4568
+ if (config.supportsOrgWide && organizationWide) {
4569
+ url = `${config.apiBase}/app-automate/recent_group_apps?limit=${limit}`;
4570
+ }
4192
4571
  const res = await fetch(url, {
4193
4572
  headers: { Authorization: `Basic ${auth}` }
4194
4573
  });
4195
4574
  if (!res.ok) {
4196
4575
  const body = await res.text();
4197
- return { isError: true, content: [{ type: "text", text: `BrowserStack API error ${res.status}: ${body}` }] };
4576
+ return { isError: true, content: [{ type: "text", text: `${config.name} API error ${res.status}: ${body}` }] };
4198
4577
  }
4199
4578
  const raw = await res.json();
4200
- let apps = Array.isArray(raw) ? raw : [];
4201
- 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());
4579
+ let apps = config.parseListResponse(raw);
4580
+ apps = sortBy === "app_name" ? apps.sort((a, b) => a.name.localeCompare(b.name)) : apps.sort((a, b) => (b.uploadedAt ?? "").localeCompare(a.uploadedAt ?? ""));
4202
4581
  return { content: [{ type: "text", text: formatAppList(apps) }] };
4203
4582
  } catch (e) {
4204
4583
  return { isError: true, content: [{ type: "text", text: `Error listing apps: ${e}` }] };
@@ -4206,28 +4585,33 @@ var listAppsTool = async ({ sortBy = "uploaded_at", organizationWide = false, li
4206
4585
  };
4207
4586
  var uploadAppToolDefinition = {
4208
4587
  name: "upload_app",
4209
- description: "Upload a local .apk or .ipa to BrowserStack App Automate. Returns a bs:// URL for use in start_session.",
4210
- annotations: { title: "Upload App to BrowserStack", destructiveHint: false },
4588
+ description: "Upload a local .apk or .ipa to a cloud provider (BrowserStack App Automate or Sauce Labs App Storage). Returns the app URL for use in start_session.",
4589
+ annotations: { title: "Upload App to Cloud Provider", destructiveHint: false },
4211
4590
  inputSchema: {
4591
+ provider: z17.enum(["browserstack", "saucelabs"]).describe("Cloud provider"),
4212
4592
  path: z17.string().describe("Absolute path to the .apk or .ipa file"),
4213
- customId: z17.string().optional().describe("Optional custom ID for the app (used to reference it later)")
4593
+ customId: z17.string().optional().describe("Optional custom ID for the app (used to reference it later)"),
4594
+ 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)")
4214
4595
  }
4215
4596
  };
4216
- var uploadAppTool = async ({ path, customId }) => {
4217
- const auth = getAuth();
4218
- if (!auth) {
4219
- return { isError: true, content: [{ type: "text", text: "Missing credentials: set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY environment variables." }] };
4597
+ var uploadAppTool = async (args) => {
4598
+ const { provider, path, customId, region = "eu-central-1" } = args;
4599
+ const resolved = getProviderConfig(provider, region);
4600
+ if ("error" in resolved) {
4601
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
4220
4602
  }
4603
+ const { config, auth } = resolved;
4221
4604
  if (!existsSync2(path)) {
4222
4605
  return { isError: true, content: [{ type: "text", text: `File not found: ${path}` }] };
4223
4606
  }
4607
+ const fileName = path.split("/").pop() ?? "app";
4224
4608
  try {
4225
4609
  const form = new FormData();
4226
- const stream = createReadStream(path);
4227
- const fileName = path.split("/").pop() ?? "app";
4228
- form.append("file", new Blob([stream]), fileName);
4610
+ const fileBuffer = readFileSync(path);
4611
+ const fileBlob = new Blob([fileBuffer], { type: "application/octet-stream" });
4612
+ form.append(config.uploadField, fileBlob, fileName);
4229
4613
  if (customId) form.append("custom_id", customId);
4230
- const res = await fetch(`${BS_API}/app-automate/upload`, {
4614
+ const res = await fetch(`${config.apiBase}${config.uploadPath}`, {
4231
4615
  method: "POST",
4232
4616
  headers: { Authorization: `Basic ${auth}` },
4233
4617
  body: form
@@ -4236,13 +4620,14 @@ var uploadAppTool = async ({ path, customId }) => {
4236
4620
  const body = await res.text();
4237
4621
  return { isError: true, content: [{ type: "text", text: `Upload failed ${res.status}: ${body}` }] };
4238
4622
  }
4239
- const data = await res.json();
4240
- const customIdNote = data.custom_id ? `
4241
- Custom ID: ${data.custom_id}` : "";
4623
+ const raw = await res.json();
4624
+ const { appRef } = config.parseUploadResponse(raw, fileName);
4625
+ const customIdNote = customId ? `
4626
+ Custom ID: ${customId}` : "";
4242
4627
  return { content: [{ type: "text", text: `Upload successful.
4243
- App URL: ${data.app_url}${customIdNote}
4628
+ App: ${appRef}${customIdNote}
4244
4629
 
4245
- Use this URL as the "app" parameter in start_session with provider: "browserstack".` }] };
4630
+ Use "${appRef}" as the "app" parameter in start_session with provider: "${provider}".` }] };
4246
4631
  } catch (e) {
4247
4632
  return { isError: true, content: [{ type: "text", text: `Error uploading app: ${e}` }] };
4248
4633
  }
@@ -4433,6 +4818,7 @@ function createServer() {
4433
4818
  registerResource(sessionStepsResource);
4434
4819
  registerResource(sessionCodeResource);
4435
4820
  registerResource(browserstackLocalBinaryResource);
4821
+ registerResource(saucelabsLocalBinaryResource);
4436
4822
  registerResource(capabilitiesResource);
4437
4823
  registerResource(elementsResource);
4438
4824
  registerResource(accessibilityResource);