@wdio/mcp 3.5.1 → 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.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",
@@ -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",
@@ -2427,6 +2428,71 @@ var browserstackLocalBinaryResource = {
2427
2428
  }
2428
2429
  };
2429
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
+
2430
2496
  // src/resources/sessions.resource.ts
2431
2497
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2432
2498
 
@@ -2453,6 +2519,7 @@ function generateStep(step, history) {
2453
2519
  case "start_session": {
2454
2520
  const platform2 = p.platform;
2455
2521
  const isBrowserStack = "bstack:options" in history.capabilities;
2522
+ const isSauceLabs = "sauce:options" in history.capabilities;
2456
2523
  const capJson = indentJson(history.capabilities).split("\n").map((line, i) => i > 0 ? ` ${line}` : line).join("\n");
2457
2524
  if (isBrowserStack) {
2458
2525
  const nav = platform2 === "browser" && p.navigationUrl ? `
@@ -2469,6 +2536,22 @@ await browser.url('${escapeStr(p.navigationUrl)}');` : "";
2469
2536
  `});${nav}`
2470
2537
  ].join("\n");
2471
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
+ }
2472
2555
  if (platform2 === "browser") {
2473
2556
  const nav = p.navigationUrl ? `
2474
2557
  await browser.url('${escapeStr(p.navigationUrl)}');` : "";
@@ -2531,8 +2614,10 @@ function bsStatusUpdateLines(sessionType) {
2531
2614
  }
2532
2615
  function generateCode(history) {
2533
2616
  const bstackOptions = history.capabilities["bstack:options"];
2617
+ const sauceOptions = history.capabilities["sauce:options"];
2534
2618
  const isBrowserStack = bstackOptions !== void 0;
2535
- const usesBrowserstackLocal = bstackOptions?.local === true;
2619
+ const isSauceLabs = sauceOptions !== void 0;
2620
+ const usesLocalTunnel = isBrowserStack ? bstackOptions?.local === true : sauceOptions?.tunnelName !== void 0;
2536
2621
  const steps = history.steps.map((step) => generateStep(step, history)).join("\n").split("\n").map((line) => ` ${line}`).join("\n");
2537
2622
  if (isBrowserStack) {
2538
2623
  const bsSteps = steps.replace(/const browser = await remote\(/g, "browser = await remote(");
@@ -2543,7 +2628,7 @@ function generateCode(history) {
2543
2628
  ${statusUpdate}
2544
2629
  await browser.deleteSession();
2545
2630
  }`;
2546
- if (usesBrowserstackLocal) {
2631
+ if (usesLocalTunnel) {
2547
2632
  const tunnelSetup = [
2548
2633
  "",
2549
2634
  "const tunnel = new BrowserstackTunnel();",
@@ -2579,6 +2664,64 @@ ${statusUpdate}
2579
2664
  "}"
2580
2665
  ].join("\n");
2581
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
+ }
2582
2725
  return `import { remote } from 'webdriverio';
2583
2726
 
2584
2727
  try {
@@ -3276,7 +3419,7 @@ import { z as z14 } from "zod";
3276
3419
  // src/session/lifecycle.ts
3277
3420
  init_state();
3278
3421
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3279
- import { join as join3 } from "path";
3422
+ import { join as join4 } from "path";
3280
3423
 
3281
3424
  // src/providers/local-browser.provider.ts
3282
3425
  var LocalBrowserProvider = class {
@@ -3531,6 +3674,13 @@ import { promisify } from "util";
3531
3674
  import { tmpdir as tmpdir2 } from "os";
3532
3675
  import { join as join2 } from "path";
3533
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
3534
3684
  var BrowserStackProvider = class {
3535
3685
  name = "browserstack";
3536
3686
  getConnectionConfig(options) {
@@ -3548,7 +3698,7 @@ var BrowserStackProvider = class {
3548
3698
  buildCapabilities(options) {
3549
3699
  const platform2 = options.platform;
3550
3700
  const userCapabilities = options.capabilities ?? {};
3551
- const browserstackLocal = options.browserstackLocal;
3701
+ const browserstackLocal = options.tunnel ?? options.browserstackLocal;
3552
3702
  if (platform2 === "browser") {
3553
3703
  const bstackOptions2 = {
3554
3704
  browserVersion: options.browserVersion ?? "latest"
@@ -3626,7 +3776,7 @@ var BrowserStackProvider = class {
3626
3776
  const key = process.env.BROWSERSTACK_ACCESS_KEY;
3627
3777
  if (!user || !key) return;
3628
3778
  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");
3779
+ const auth = basicAuth(user, key);
3630
3780
  const body = { status: result.status, ...result.reason ? { reason: result.reason } : {} };
3631
3781
  await fetch(`${baseUrl}/${sessionId}.json`, {
3632
3782
  method: "PUT",
@@ -3637,11 +3787,153 @@ var BrowserStackProvider = class {
3637
3787
  };
3638
3788
  var browserStackProvider = new BrowserStackProvider();
3639
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
+
3640
3926
  // src/providers/registry.ts
3641
- function getProvider(providerName, platform2) {
3642
- if (providerName === "browserstack") return browserStackProvider;
3927
+ var providers = /* @__PURE__ */ new Map([
3928
+ ["browserstack", browserStackProvider],
3929
+ ["saucelabs", sauceLabsProvider]
3930
+ ]);
3931
+ function getDefaultProvider(platform2) {
3643
3932
  return platform2 === "browser" ? localBrowserProvider : localAppiumProvider;
3644
3933
  }
3934
+ function getProvider(providerName, platform2) {
3935
+ return providers.get(providerName) ?? getDefaultProvider(platform2);
3936
+ }
3645
3937
 
3646
3938
  // src/trace/zip-writer.ts
3647
3939
  import yazl from "yazl";
@@ -3671,10 +3963,10 @@ async function finalizeTrace(sessionId, browser) {
3671
3963
  if (!traceSession) return;
3672
3964
  try {
3673
3965
  await traceSession.screenshotChain;
3674
- const traceDir = join3(process.cwd(), ".trace");
3966
+ const traceDir = join4(process.cwd(), ".trace");
3675
3967
  mkdirSync2(traceDir, { recursive: true });
3676
3968
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3677
- const outPath = join3(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
3969
+ const outPath = join4(traceDir, `${timestamp}-${sessionId.slice(0, 8)}.zip`);
3678
3970
  const zipBuffer = await buildTraceZip(traceSession);
3679
3971
  writeFileSync2(outPath, zipBuffer);
3680
3972
  console.error(`[TRACE] Saved to ${outPath}`);
@@ -3726,11 +4018,16 @@ function registerSession(sessionId, browser, metadata, historyEntry) {
3726
4018
  if (oldMetadata?.provider) {
3727
4019
  const oldHistory = state2.sessionHistory.get(oldSessionId);
3728
4020
  const provider = getProvider(oldMetadata.provider, oldMetadata.type);
3729
- 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(() => {
3730
4022
  });
3731
4023
  }
3732
4024
  await oldBrowser.deleteSession().catch(() => {
3733
4025
  });
4026
+ if (oldMetadata?.provider && oldMetadata?.tunnelHandle) {
4027
+ const provider = getProvider(oldMetadata.provider, oldMetadata.type);
4028
+ await provider.stopTunnel?.(oldMetadata.tunnelHandle).catch(() => {
4029
+ });
4030
+ }
3734
4031
  })();
3735
4032
  state2.browsers.delete(oldSessionId);
3736
4033
  state2.sessionMetadata.delete(oldSessionId);
@@ -3753,12 +4050,20 @@ async function closeSession(sessionId, detach, isAttached, force) {
3753
4050
  if (metadata?.provider) {
3754
4051
  try {
3755
4052
  const provider = getProvider(metadata.provider, metadata.type);
3756
- await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle);
4053
+ await provider.onSessionClose?.(sessionId, metadata.type, getSessionResult(history), metadata.tunnelHandle, browser, metadata.region);
3757
4054
  } catch (e) {
3758
4055
  console.error("[WARN] Failed to run provider onSessionClose:", e);
3759
4056
  }
3760
4057
  }
3761
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
+ }
3762
4067
  }
3763
4068
  state2.browsers.delete(sessionId);
3764
4069
  state2.sessionMetadata.delete(sessionId);
@@ -3776,18 +4081,18 @@ var startSessionToolDefinition = {
3776
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.',
3777
4082
  annotations: { title: "Start Session", destructiveHint: false },
3778
4083
  inputSchema: {
3779
- 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)"),
3780
4085
  platform: platformEnum.describe("Session platform type"),
3781
4086
  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"),
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"),
3786
4091
  reporting: z14.object({
3787
4092
  project: z14.string().optional(),
3788
4093
  build: z14.string().optional(),
3789
4094
  session: z14.string().optional()
3790
- }).optional().describe("BrowserStack reporting labels (project, build, session)"),
4095
+ }).optional().describe("Cloud provider reporting labels (project, build, session)"),
3791
4096
  headless: coerceBoolean.optional().default(true).describe("Run browser in headless mode (default: true)"),
3792
4097
  windowWidth: z14.number().min(400).max(3840).optional().default(1920).describe("Browser window width"),
3793
4098
  windowHeight: z14.number().min(400).max(2160).optional().default(1080).describe("Browser window height"),
@@ -3815,7 +4120,11 @@ var startSessionToolDefinition = {
3815
4120
  path: z14.string().optional(),
3816
4121
  protocol: z14.string().optional()
3817
4122
  }).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.'),
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.'),
3819
4128
  navigationUrl: z14.string().optional().describe("URL to navigate to after starting"),
3820
4129
  capabilities: z14.record(z14.string(), z14.unknown()).optional().describe("Additional capabilities to merge")
3821
4130
  }
@@ -3894,15 +4203,19 @@ async function startBrowserSession(args) {
3894
4203
  const effectiveHeadless = headless && headlessSupported;
3895
4204
  const provider = getProvider(args.provider ?? "local", "browser");
3896
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;
3897
4209
  const mergedCapabilities = provider.buildCapabilities({
3898
4210
  ...args,
3899
4211
  browser,
3900
4212
  headless,
3901
4213
  windowWidth,
3902
4214
  windowHeight,
3903
- capabilities: userCapabilities
4215
+ capabilities: userCapabilities,
4216
+ tunnelName
3904
4217
  });
3905
- const tunnelHandle = args.browserstackLocal === true ? await provider.startTunnel?.(args) : void 0;
4218
+ const tunnelHandle = tunnelEnabled ? await provider.startTunnel?.({ ...args, tunnelName }) : void 0;
3906
4219
  const wdioBrowser = await remote({ ...connectionConfig, capabilities: mergedCapabilities });
3907
4220
  const { sessionId } = wdioBrowser;
3908
4221
  const sessionMetadata = {
@@ -3910,6 +4223,8 @@ async function startBrowserSession(args) {
3910
4223
  capabilities: mergedCapabilities,
3911
4224
  isAttached: false,
3912
4225
  provider: args.provider ?? "local",
4226
+ region: args.region,
4227
+ tunnelName,
3913
4228
  tunnelHandle,
3914
4229
  trace: args.trace ?? false
3915
4230
  };
@@ -3958,8 +4273,11 @@ async function startMobileSession(args) {
3958
4273
  }
3959
4274
  const provider = getProvider(args.provider ?? "local", args.platform);
3960
4275
  const serverConfig = provider.getConnectionConfig(args);
3961
- const mergedCapabilities = provider.buildCapabilities(args);
3962
- 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;
3963
4281
  const browser = await remote({ ...serverConfig, capabilities: mergedCapabilities });
3964
4282
  const { sessionId } = browser;
3965
4283
  const shouldAutoDetach = provider.shouldAutoDetach(args);
@@ -3969,6 +4287,8 @@ async function startMobileSession(args) {
3969
4287
  capabilities: mergedCapabilities,
3970
4288
  isAttached: shouldAutoDetach,
3971
4289
  provider: args.provider ?? "local",
4290
+ region: args.region,
4291
+ tunnelName,
3972
4292
  tunnelHandle,
3973
4293
  trace: args.trace ?? false
3974
4294
  };
@@ -4152,51 +4472,112 @@ var switchFrameTool = async ({
4152
4472
  }
4153
4473
  };
4154
4474
 
4155
- // src/tools/browserstack.tool.ts
4156
- import { existsSync as existsSync2, createReadStream } from "fs";
4475
+ // src/tools/cloud-provider.tool.ts
4476
+ import { existsSync as existsSync2, readFileSync } from "fs";
4157
4477
  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
4478
  function formatAppList(apps) {
4166
4479
  if (apps.length === 0) return "No apps found.";
4167
4480
  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})`;
4481
+ const id = a.customId ? ` [${a.customId}]` : "";
4482
+ const ts = a.uploadedAt ?? "unknown";
4483
+ return `${a.name}${id} \u2014 ${a.ref} (${ts})`;
4170
4484
  }).join("\n");
4171
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
+ }
4172
4547
  var listAppsToolDefinition = {
4173
4548
  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 },
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 },
4176
4551
  inputSchema: {
4552
+ provider: z17.enum(["browserstack", "saucelabs"]).describe("Cloud provider"),
4177
4553
  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)")
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)")
4180
4557
  }
4181
4558
  };
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." }] };
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 }] };
4186
4564
  }
4565
+ const { config, auth } = resolved;
4187
4566
  try {
4188
- let url = `${BS_API}/app-automate/${organizationWide ? "recent_group_apps" : "recent_apps"}`;
4189
- 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
+ }
4190
4571
  const res = await fetch(url, {
4191
4572
  headers: { Authorization: `Basic ${auth}` }
4192
4573
  });
4193
4574
  if (!res.ok) {
4194
4575
  const body = await res.text();
4195
- 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}` }] };
4196
4577
  }
4197
4578
  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());
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 ?? ""));
4200
4581
  return { content: [{ type: "text", text: formatAppList(apps) }] };
4201
4582
  } catch (e) {
4202
4583
  return { isError: true, content: [{ type: "text", text: `Error listing apps: ${e}` }] };
@@ -4204,28 +4585,33 @@ var listAppsTool = async ({ sortBy = "uploaded_at", organizationWide = false, li
4204
4585
  };
4205
4586
  var uploadAppToolDefinition = {
4206
4587
  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 },
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 },
4209
4590
  inputSchema: {
4591
+ provider: z17.enum(["browserstack", "saucelabs"]).describe("Cloud provider"),
4210
4592
  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)")
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)")
4212
4595
  }
4213
4596
  };
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." }] };
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 }] };
4218
4602
  }
4603
+ const { config, auth } = resolved;
4219
4604
  if (!existsSync2(path)) {
4220
4605
  return { isError: true, content: [{ type: "text", text: `File not found: ${path}` }] };
4221
4606
  }
4607
+ const fileName = path.split("/").pop() ?? "app";
4222
4608
  try {
4223
4609
  const form = new FormData();
4224
- const stream = createReadStream(path);
4225
- const fileName = path.split("/").pop() ?? "app";
4226
- 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);
4227
4613
  if (customId) form.append("custom_id", customId);
4228
- const res = await fetch(`${BS_API}/app-automate/upload`, {
4614
+ const res = await fetch(`${config.apiBase}${config.uploadPath}`, {
4229
4615
  method: "POST",
4230
4616
  headers: { Authorization: `Basic ${auth}` },
4231
4617
  body: form
@@ -4234,13 +4620,14 @@ var uploadAppTool = async ({ path, customId }) => {
4234
4620
  const body = await res.text();
4235
4621
  return { isError: true, content: [{ type: "text", text: `Upload failed ${res.status}: ${body}` }] };
4236
4622
  }
4237
- const data = await res.json();
4238
- const customIdNote = data.custom_id ? `
4239
- 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}` : "";
4240
4627
  return { content: [{ type: "text", text: `Upload successful.
4241
- App URL: ${data.app_url}${customIdNote}
4628
+ App: ${appRef}${customIdNote}
4242
4629
 
4243
- 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}".` }] };
4244
4631
  } catch (e) {
4245
4632
  return { isError: true, content: [{ type: "text", text: `Error uploading app: ${e}` }] };
4246
4633
  }
@@ -4431,6 +4818,7 @@ function createServer() {
4431
4818
  registerResource(sessionStepsResource);
4432
4819
  registerResource(sessionCodeResource);
4433
4820
  registerResource(browserstackLocalBinaryResource);
4821
+ registerResource(saucelabsLocalBinaryResource);
4434
4822
  registerResource(capabilitiesResource);
4435
4823
  registerResource(elementsResource);
4436
4824
  registerResource(accessibilityResource);