@browserstack/mcp-server 2.3.0-beta.5 → 2.3.0-beta.6

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.
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import { startSession } from "./applive-utils/start-session.js";
4
4
  import logger from "../logger.js";
5
5
  import { trackMCP } from "../lib/instrumentation.js";
6
+ import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js";
6
7
  /**
7
8
  * Launches an App Live Session on BrowserStack.
8
9
  */
@@ -54,7 +55,15 @@ export async function startAppLiveSession(args, config) {
54
55
  }
55
56
  export default function addAppLiveTools(server, config) {
56
57
  const tools = {};
57
- tools.runAppLiveSession = server.tool("runAppLiveSession", "Use this tool when user wants to manually check their app on a particular mobile device using BrowserStack's cloud infrastructure. Can be used to debug crashes, slow performance, etc.", {
58
+ // A Live session is for a PERSON. The agent cannot tap, type, read the screen or take a
59
+ // screenshot in it, and every eval where an agent was asked to operate an app and chose
60
+ // this tool ended with "my tools can start it but can't tap anything in it". The
61
+ // description says so, and names the surface that can — only when that surface is on.
62
+ const description = "Open a manual App Live session for the user to operate by hand. It cannot be driven by the agent." +
63
+ (capabilityRegistryEnabled()
64
+ ? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability."
65
+ : "");
66
+ tools.runAppLiveSession = server.tool("runAppLiveSession", description, {
58
67
  desiredPhone: z
59
68
  .string()
60
69
  .describe("The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20' or 'Google Pixel 6'. Always ask the user for the device they want to use, do not assume it. "),
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Try the next thing when the product refuses, as the index says to.
3
+ *
4
+ * ONE REQUEST, ONE RESPONSE is still the rule for every capability that declares nothing
5
+ * here. A `fallbacks` list is the one authored exception: when THIS call answers a given
6
+ * status, the index knows a sibling capability that may do the job, and names it with the
7
+ * body to send. The chain exists for hide_keyboard on an iPhone, where the plain call
8
+ * answers 400 because the keyboard has no dismiss key, and the only way on is to press one
9
+ * of its own keys — Done where it exists, Return where it does not.
10
+ *
11
+ * The caller's consent covers the chain: it was given for the capability invoked, and the
12
+ * chain is that capability's published contract. What a step may do — press Return, which
13
+ * an app may treat as submit — is therefore the capability's guidance to state, not
14
+ * something this module decides. It does refuse two things no guidance could make right: a
15
+ * step naming a destructive capability, and one naming a capability this surface will not
16
+ * call.
17
+ *
18
+ * Every attempt is reported, in order, with the status it got, so the caller knows which
19
+ * step did the work and can read the screen back afterwards.
20
+ */
21
+ import { GroupedArguments } from "./bind.js";
22
+ import { InvokeResult } from "./resolve.js";
23
+ import { Capability } from "./types.js";
24
+ export interface Attempt {
25
+ capability: string;
26
+ status: number;
27
+ /** The authored body the step sent, so the caller can see which keys were tried. */
28
+ body?: Record<string, unknown>;
29
+ }
30
+ export interface FallbackOutcome {
31
+ /** The last response: the first 2xx, or the final refusal. */
32
+ result: InvokeResult;
33
+ /** Present only when the chain ran; the first entry is the original call. */
34
+ attempts?: Attempt[];
35
+ }
36
+ /** Makes one request for one capability — `invoke`, with the deps already bound. */
37
+ export type Runner = (capability: Capability, args: GroupedArguments) => Promise<InvokeResult>;
38
+ /** Resolves a step's name within the product of the capability being invoked. */
39
+ export type Lookup = (name: string) => Capability;
40
+ export declare function invokeWithFallbacks(capability: Capability, args: GroupedArguments, run: Runner, lookup: Lookup): Promise<FallbackOutcome>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Try the next thing when the product refuses, as the index says to.
3
+ *
4
+ * ONE REQUEST, ONE RESPONSE is still the rule for every capability that declares nothing
5
+ * here. A `fallbacks` list is the one authored exception: when THIS call answers a given
6
+ * status, the index knows a sibling capability that may do the job, and names it with the
7
+ * body to send. The chain exists for hide_keyboard on an iPhone, where the plain call
8
+ * answers 400 because the keyboard has no dismiss key, and the only way on is to press one
9
+ * of its own keys — Done where it exists, Return where it does not.
10
+ *
11
+ * The caller's consent covers the chain: it was given for the capability invoked, and the
12
+ * chain is that capability's published contract. What a step may do — press Return, which
13
+ * an app may treat as submit — is therefore the capability's guidance to state, not
14
+ * something this module decides. It does refuse two things no guidance could make right: a
15
+ * step naming a destructive capability, and one naming a capability this surface will not
16
+ * call.
17
+ *
18
+ * Every attempt is reported, in order, with the status it got, so the caller knows which
19
+ * step did the work and can read the screen back afterwards.
20
+ */
21
+ import { InvocationError } from "./index-loader.js";
22
+ function handle(capability) {
23
+ return capability.name || `${capability.method} ${capability.path}`;
24
+ }
25
+ export async function invokeWithFallbacks(capability, args, run, lookup) {
26
+ let result = await run(capability, args);
27
+ const chain = capability.fallbacks ?? [];
28
+ if (result.ok || chain.length === 0)
29
+ return { result };
30
+ const attempts = [
31
+ { capability: handle(capability), status: result.http_response.status },
32
+ ];
33
+ for (const step of chain) {
34
+ // Matched against the LATEST status, not the first: a step that answers something
35
+ // the next step does not name ends the chain there, with that answer.
36
+ if (result.http_response.status !== step.when.status)
37
+ continue;
38
+ const next = lookup(step.capability);
39
+ if (next.mode === "destructive")
40
+ throw new InvocationError(`${handle(capability)} names ${step.capability} as a fallback, which is destructive and cannot run`);
41
+ if (next.invocable === false)
42
+ throw new InvocationError(`${handle(capability)} names ${step.capability} as a fallback, which cannot be called on this server`);
43
+ // Only the path parameters travel: the step's body and query are the index's own, so
44
+ // a caller cannot steer the chain into sending something the contract never showed.
45
+ result = await run(next, {
46
+ path_params: args.path_params,
47
+ query: step.query,
48
+ body: step.body,
49
+ });
50
+ attempts.push({
51
+ capability: step.capability,
52
+ status: result.http_response.status,
53
+ ...(step.body ? { body: step.body } : {}),
54
+ });
55
+ if (result.ok)
56
+ break;
57
+ }
58
+ // A chain that matched nothing is not a chain that ran: the caller sees the plain
59
+ // refusal, as it would from a capability that declares no fallbacks at all.
60
+ return attempts.length > 1 ? { result, attempts } : { result };
61
+ }
@@ -100,14 +100,53 @@ function asReference(capability) {
100
100
  ]) {
101
101
  delete reference[field];
102
102
  }
103
+ // Guidance is NOT touched here. `withSurfaceGuidance` has already folded in
104
+ // `remote_guidance` — which is where a reference-only capability says what to do
105
+ // instead — and it runs for every capability, not only this branch.
106
+ // `withSurfaceGuidance` has already run, so guidance is a plain array here — and for a
107
+ // reference-only capability its `remote` half is where "what to do instead" lives.
108
+ const named = Array.isArray(capability.guidance)
109
+ ? capability.guidance.length > 0
110
+ : false;
103
111
  return {
104
112
  ...reference,
105
113
  invocable: false,
106
- guidance: capability.unavailable_note
107
- ? [capability.unavailable_note]
108
- : ["This capability cannot be called on this server."],
114
+ ...(named
115
+ ? {}
116
+ : { guidance: ["This capability cannot be called on this server."] }),
109
117
  };
110
118
  }
119
+ /**
120
+ * Flatten `guidance` to the lines that apply to the surface doing the loading.
121
+ *
122
+ * A capability may say three things about how to use it: what is true everywhere, what is
123
+ * true only where the server sits beside the user, and what is true only on a remote one.
124
+ * Only one of the last two can ever apply, so the caller is handed exactly that and never
125
+ * has to work out which half was meant for them.
126
+ *
127
+ * COMPOSED, NOT REPLACED. An earlier shape put the remote instruction in a single
128
+ * `unavailable_note` that replaced the guidance outright, which threw away lines still true
129
+ * there — the accepted file types, how long a large upload takes — and invited them to be
130
+ * restated in the note, where the two copies could drift. Shared lines are now written once.
131
+ *
132
+ * The object form is flattened away here, so no reader downstream sees anything but a plain
133
+ * array, and which surface this is stays ours to know rather than a fact the caller acts on.
134
+ */
135
+ function withSurfaceGuidance(capability) {
136
+ const g = capability.guidance;
137
+ if (!g || Array.isArray(g))
138
+ return capability;
139
+ const lines = [
140
+ ...(g.shared ?? []),
141
+ ...(appConfig.REMOTE_MCP ? (g.remote ?? []) : (g.local ?? [])),
142
+ ];
143
+ const next = { ...capability };
144
+ if (lines.length)
145
+ next.guidance = lines;
146
+ else
147
+ delete next.guidance;
148
+ return next;
149
+ }
111
150
  function isPlainObject(value) {
112
151
  return typeof value === "object" && value !== null && !Array.isArray(value);
113
152
  }
@@ -237,11 +276,14 @@ export class CapabilityRegistry {
237
276
  const names = new Map();
238
277
  const withheld = new Map();
239
278
  const enabled = [];
240
- for (const capability of bundle.capabilities) {
279
+ for (let capability of bundle.capabilities) {
241
280
  // UNIQUENESS IS CHECKED OVER EVERY CAPABILITY, disabled ones included. A clash
242
281
  // between a disabled name and a live one is still a broken artifact, and skipping
243
282
  // the disabled half would mean re-enabling the flag could fail the load later,
244
283
  // somewhere with less context than here.
284
+ // Surface guidance is folded in for EVERY capability, before any of the branches
285
+ // below, so a reference-only one and an ordinary one are composed the same way.
286
+ capability = withSurfaceGuidance(capability);
245
287
  if (isReferenceOnly(capability)) {
246
288
  // PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that
247
289
  // should find it does, and loses everything a caller would need to invoke it.
@@ -22,8 +22,10 @@ import { redact } from "./redact.js";
22
22
  import { fetchTransport } from "./egress.js";
23
23
  import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
24
24
  import { narrowResponse } from "./narrow.js";
25
+ import { invokeWithFallbacks } from "./fallback.js";
25
26
  import { invoke } from "./resolve.js";
26
27
  import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
28
+ import { guidanceLines } from "./types.js";
27
29
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
28
30
  /**
29
31
  * The search-side twin of `user_permission`: did a human choose the product, or did you?
@@ -702,8 +704,8 @@ export function addCapabilityRegistryTools(server, deps, config) {
702
704
  mode: capability.mode,
703
705
  entity: capability.entity,
704
706
  ...(capability.intent ? { intent: capability.intent } : {}),
705
- ...(capability.guidance?.length
706
- ? { guidance: capability.guidance }
707
+ ...(guidanceLines(capability.guidance).length
708
+ ? { guidance: guidanceLines(capability.guidance) }
707
709
  : {}),
708
710
  })),
709
711
  ...rest,
@@ -967,7 +969,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
967
969
  // refusal carries the instruction, because the note is the whole point of it
968
970
  // still being published.
969
971
  return refuse("invokeCapability", "capability_not_invocable", `${handle} cannot be called on this server. ` +
970
- (capability.guidance?.[0] ??
972
+ (guidanceLines(capability.guidance)[0] ??
971
973
  "Describe it for what to do instead.") +
972
974
  " Follow that instead of retrying.", undefined, identity);
973
975
  }
@@ -993,7 +995,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
993
995
  return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
994
996
  }
995
997
  }
996
- const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
998
+ // The base URL is resolved per target rather than once: a fallback step may sit
999
+ // on a different named host from the capability that declared it.
1000
+ const { result, attempts } = await invokeWithFallbacks(capability, args, async (target, targetArgs) => invoke(target, targetArgs, await deps.baseUrlFor(product, target.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product), (name) => registry.byNameLookup(name, product).capability);
997
1001
  // NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller
998
1002
  // needs whole, and a selection that happened to match none of its keys would
999
1003
  // quietly empty it. `narrowResponse` refuses an unsupported `select` by throwing,
@@ -1001,13 +1005,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
1001
1005
  const narrowed = result.ok
1002
1006
  ? narrowResponse(capability, result.http_response.body, input.select)
1003
1007
  : undefined;
1004
- const answer = narrowed
1005
- ? {
1006
- ...result,
1007
- http_response: { ...result.http_response, body: narrowed.body },
1008
- narrowed: narrowed.narrowing,
1009
- }
1010
- : result;
1008
+ const answer = {
1009
+ ...(narrowed
1010
+ ? {
1011
+ ...result,
1012
+ http_response: { ...result.http_response, body: narrowed.body },
1013
+ narrowed: narrowed.narrowing,
1014
+ }
1015
+ : result),
1016
+ // Only when the chain ran: a capability without `fallbacks` answers exactly as
1017
+ // before, and one whose first call succeeded has nothing to report.
1018
+ ...(attempts ? { fallback: { attempts } } : {}),
1019
+ };
1011
1020
  // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
1012
1021
  // throwing, so `success` keeps its tool-level meaning and these two fields carry
1013
1022
  // whether the product call worked. Status 0 means it could not be reached.
@@ -1016,6 +1025,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
1016
1025
  upstream_ok: result.ok,
1017
1026
  upstream_status: result.http_response.status,
1018
1027
  change_summary: redact(input.change_summary),
1028
+ ...(attempts ? { fallback_steps: attempts.length - 1 } : {}),
1019
1029
  ...(narrowed
1020
1030
  ? {
1021
1031
  narrowed_from: narrowed.narrowing.from,
@@ -11,6 +11,7 @@
11
11
  * * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
12
12
  * capability needing an id it cannot possibly have yet.
13
13
  */
14
+ import { guidanceLines, } from "./types.js";
14
15
  /**
15
16
  * Every non-alphanumeric character separates, `_` included.
16
17
  *
@@ -408,7 +409,7 @@ function score(capability, wanted, weights, aliases, hint, plural) {
408
409
  // `test_cases_count`); gating it on an identity match instead made a field reachable
409
410
  // only through returns unreachable, which is worse.
410
411
  [(capability.returns || []).join(" "), 1],
411
- [(capability.guidance || []).join(" "), 1],
412
+ [guidanceLines(capability.guidance).join(" "), 1],
412
413
  // Parameter names, their descriptions, and their enum values — 330 descriptions and 34
413
414
  // value lists that the artifact already carries and nothing was reading. The vocabulary
414
415
  // a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere
@@ -102,6 +102,53 @@ export interface ResponseDoc extends ComponentRef {
102
102
  schema?: SchemaNode;
103
103
  }
104
104
  /** A capability, keyed by the endpoint it exposes. There is deliberately no name. */
105
+ /**
106
+ * How to call the endpoint correctly, optionally split by which surface is serving it.
107
+ *
108
+ * A plain array is shared — true wherever this runs, which is almost everything, and the
109
+ * form every shipped index already uses. The object form adds lines that are true on only
110
+ * one surface: `local` where the server sits beside the user, `remote` where it does not.
111
+ * `shared` is the same list the plain array would have been, so a capability can carry
112
+ * facts of both kinds without repeating any of them.
113
+ *
114
+ * The loader folds the applicable half into one array before anything reads it, so a
115
+ * caller is handed exactly the guidance that applies to the server it is talking to and
116
+ * never has to work out which half was meant for it. The split itself is ours to know:
117
+ * these keys do not survive into what describeCapability or searchCapability return.
118
+ *
119
+ * WHY A LINE IS SURFACE-SPECIFIC RATHER THAN SHARED: on a remote server, advice about the
120
+ * caller's own filesystem is not merely useless but wrong — it would have an agent tell the
121
+ * user to configure a machine they are not on. And for a `local_only` capability, `remote`
122
+ * is where "what to do instead" lives, which searchCapability puts in front of the caller
123
+ * before they pick anything, let alone call it.
124
+ */
125
+ export type Guidance = string[] | {
126
+ shared?: string[];
127
+ local?: string[];
128
+ remote?: string[];
129
+ };
130
+ /**
131
+ * The guidance lines as a plain array, whichever form was authored.
132
+ *
133
+ * The loader flattens the object form at load, so everything downstream genuinely does hold
134
+ * an array — this exists so that is checked rather than asserted, and so a capability read
135
+ * straight from a file (a test, a checker) behaves the same as one from the registry.
136
+ * Without the surface to decide between them, only the shared lines can be returned.
137
+ */
138
+ export declare function guidanceLines(guidance?: Guidance): string[];
139
+ /** One step of a capability's `fallbacks` chain. */
140
+ export interface Fallback {
141
+ /** Runs when the latest response carried this status. */
142
+ when: {
143
+ status: number;
144
+ };
145
+ /** The sibling capability to call, by published name, in the same product. */
146
+ capability: string;
147
+ /** The body to send it — authored, never the caller's. */
148
+ body?: Record<string, unknown>;
149
+ /** Query parameters to send it, if it takes any. */
150
+ query?: Record<string, unknown>;
151
+ }
105
152
  export interface Capability {
106
153
  /**
107
154
  * The caller-facing handle — the snake-cased `operationId`, unique within its product.
@@ -139,14 +186,8 @@ export interface Capability {
139
186
  query?: WireParam[];
140
187
  body?: WireParam[];
141
188
  intent?: string;
142
- /**
143
- * How to call the endpoint correctly.
144
- *
145
- * ABSENT from the released artifact — the export dropped it, and the shape-change note
146
- * does not say whether that was intended. Kept optional and still scored by search so the
147
- * server works either way; nothing may depend on it being present.
148
- */
149
- guidance?: string[];
189
+ /** How to call it; see `Guidance` for the local/remote split. */
190
+ guidance?: Guidance;
150
191
  /** Allowlisted row fields. Absent when `shape` is "discovered". */
151
192
  returns?: string[];
152
193
  /** "discovered" when the product declares no response schema for this operation. */
@@ -189,18 +230,6 @@ export interface Capability {
189
230
  * than hunting for a synonym.
190
231
  */
191
232
  local_only?: boolean;
192
- /**
193
- * What to do instead, on a surface where this capability cannot run.
194
- *
195
- * A `local_only` capability is NOT hidden on a remote server: the query that should find
196
- * it still finds it, because an agent asked to upload a file from the user's machine
197
- * needs to learn that the route exists and what replaces it here. What it loses is the
198
- * means to call it — the parameters and response shapes are stripped at load, and
199
- * invocation is refused — so the note is the whole of what remains.
200
- *
201
- * Write it as the instruction the caller should follow, not as an apology.
202
- */
203
- unavailable_note?: string;
204
233
  /**
205
234
  * FALSE when this build publishes the capability for reference but will not call it.
206
235
  *
@@ -255,6 +284,21 @@ export interface Capability {
255
284
  /** One line for the contract: why this is here rather than on the wire. */
256
285
  note?: string;
257
286
  };
287
+ /**
288
+ * What to try next when the product refuses THIS call, in order.
289
+ *
290
+ * The one authored exception to one-request-one-response. Each step names a sibling
291
+ * capability in the same product and the fixed body to send it; the original call's
292
+ * path parameters are carried over, nothing else. A step runs only when the LATEST
293
+ * response has the status it names, and the chain stops at the first 2xx.
294
+ *
295
+ * Exists for hide_keyboard on an iPhone: the keyboard has no dismiss key, so the plain
296
+ * call is refused, and the only way on is to press one of the keys it does carry. The
297
+ * caller's consent covers the chain, because the chain is this capability's published
298
+ * contract — so what a step may do (press Return, which an app may treat as submit) is
299
+ * the capability's guidance to state. Every attempt is reported with its status.
300
+ */
301
+ fallbacks?: Fallback[];
258
302
  paginated?: boolean;
259
303
  /** The largest page the operation declares. */
260
304
  max_page_size?: number;
@@ -31,3 +31,16 @@ export const ENVELOPE_KEYS = [
31
31
  "harness_commit",
32
32
  "products",
33
33
  ];
34
+ /**
35
+ * The guidance lines as a plain array, whichever form was authored.
36
+ *
37
+ * The loader flattens the object form at load, so everything downstream genuinely does hold
38
+ * an array — this exists so that is checked rather than asserted, and so a capability read
39
+ * straight from a file (a test, a checker) behaves the same as one from the registry.
40
+ * Without the surface to decide between them, only the shared lines can be returned.
41
+ */
42
+ export function guidanceLines(guidance) {
43
+ if (!guidance)
44
+ return [];
45
+ return Array.isArray(guidance) ? guidance : (guidance.shared ?? []);
46
+ }
@@ -4,7 +4,7 @@ import { apiClient } from "../../lib/apiClient.js";
4
4
  import { wrapUntrusted } from "../../lib/untrusted-content.js";
5
5
  // DEVICE LOGS
6
6
  export async function retrieveDeviceLogs(sessionId, buildId, config) {
7
- const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/deviceLogs`;
7
+ const url = `https://api.browserstack.com/app-automate/builds/${buildId}/sessions/${sessionId}/devicelogs`;
8
8
  const authString = getBrowserStackAuth(config);
9
9
  const auth = Buffer.from(authString).toString("base64");
10
10
  const response = await apiClient.get({
@@ -3,6 +3,7 @@ import logger from "../logger.js";
3
3
  import { startBrowserSession } from "./live-utils/start-session.js";
4
4
  import { PlatformType } from "./live-utils/types.js";
5
5
  import { trackMCP } from "../lib/instrumentation.js";
6
+ import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js";
6
7
  import globalConfig from "../config.js";
7
8
  // Define the schema shape
8
9
  const LiveArgsShape = {
@@ -88,7 +89,13 @@ async function runBrowserSession(rawArgs, config) {
88
89
  }
89
90
  export default function addBrowserLiveTools(server, config) {
90
91
  const tools = {};
91
- tools.runBrowserLiveSession = server.tool("runBrowserLiveSession", "Launch a BrowserStack Live session (desktop or mobile).", LiveArgsShape, {
92
+ // Same rule as runAppLiveSession: Live is for a person, and an agent that picks it for a
93
+ // click-by-locator task ends up reporting that Live cannot click by locator.
94
+ const description = "Open a manual Live browser session (desktop or mobile) for the user to operate by hand. It cannot be driven by the agent." +
95
+ (capabilityRegistryEnabled()
96
+ ? " To click, type or read the page yourself, use a devicecontrol session via invokeCapability."
97
+ : "");
98
+ tools.runBrowserLiveSession = server.tool("runBrowserLiveSession", description, LiveArgsShape, {
92
99
  title: "Run Browser Live Session",
93
100
  readOnlyHint: false,
94
101
  openWorldHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.3.0-beta.5",
3
+ "version": "2.3.0-beta.6",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",