Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
3bd4cea
feat(capability-registry): let one product span two hosts, and onboar…
sarve-shreyas Oct 15, 2026
929db21
feat(capability-registry): withhold local-machine capabilities on a r…
sarve-shreyas Oct 16, 2026
6ed4e97
feat(capability-registry): upload a build that is on the caller's own…
sarve-shreyas Oct 16, 2026
4634141
fix(capability-registry): a withheld capability answers as absent, no…
sarve-shreyas Oct 16, 2026
3881984
feat(capability-registry): publish a local-only capability for refere…
sarve-shreyas Oct 16, 2026
7380001
feat(capability-registry): onboard Automate, the web half of the brow…
sarve-shreyas Oct 16, 2026
da1bf6f
chore(capability-registry): publish only what the eval suite exercises
sarve-shreyas Oct 16, 2026
29c2509
chore: bump version to 2.3.0
sarve-shreyas Oct 16, 2026
0f13e27
fix(automate): a newline does not submit a form on Safari for iOS
sarve-shreyas Oct 16, 2026
8a94122
feat(capability-registry): find_elements on App Automate, and three c…
sarve-shreyas Oct 16, 2026
3ea6346
chore(capability-registry): publish get_page_source and get_window_re…
sarve-shreyas Oct 16, 2026
a070803
refactor(capability-registry): name the products for what they contro…
sarve-shreyas Oct 16, 2026
6782f7e
fix(capability): stop the indexes pointing at capabilities they withhold
sarve-shreyas Oct 16, 2026
d9163af
fix(capability-registry): keep the wire fields out of describeCapability
sarve-shreyas Oct 16, 2026
e894118
fix(capability-registry): stop products claiming words they do not own
sarve-shreyas Oct 16, 2026
8445466
feat(capability-registry): let a capability declare that we narrow it…
sarve-shreyas Oct 19, 2026
cd1a98e
chore(capability): stamp the two new indexes the way the format says
sarve-shreyas Oct 19, 2026
e182de0
Merge remote-tracking branch 'origin/main' into infra-capability
sarve-shreyas Oct 19, 2026
0013fed
fix(capability): say what the iOS keyboard actually does
sarve-shreyas Oct 21, 2026
78faaee
fix(capability): say WHERE the app id goes, and which scroll actually…
sarve-shreyas Oct 21, 2026
39a6845
fix(capability): the iOS keyboard submit is the app's doing, not Retu…
sarve-shreyas Oct 21, 2026
bdb7dad
fix(capability): language and locale land on the device or the app, b…
sarve-shreyas Oct 21, 2026
587fa19
chore(capability): withhold get_page_source on devicecontrol
sarve-shreyas Oct 22, 2026
8df3db1
feat(capability): hand the user a live-view link when a session starts
sarve-shreyas Oct 22, 2026
2644987
fix(capability): name all three ways to get a bs:// url, and what the…
sarve-shreyas Oct 22, 2026
ee04046
refactor(capability-registry): guidance splits by surface, replacing …
sarve-shreyas Oct 22, 2026
13cc246
fix(capability): measure the two keyboard tricks PR 485 uses, and rec…
sarve-shreyas Oct 22, 2026
167431f
feat(capability): clear_element on mobile, and the claim it disproves
sarve-shreyas Oct 22, 2026
0fe631b
feat(capability): wrap the jobs execute_script was doing, and stop pu…
sarve-shreyas Oct 22, 2026
8937248
fix(capability): pin every wrapper's script, and give iOS its own scroll
sarve-shreyas Oct 22, 2026
433a004
chore(capability): drop the plumbing talk from the pinned script para…
sarve-shreyas Oct 22, 2026
e57d489
fix(capability): correct the five scroll and keyboard wrappers from a…
sarve-shreyas Oct 22, 2026
c9ac311
fix(capability): hide_keyboard fails on an iPhone, not on iOS
sarve-shreyas Oct 22, 2026
a66aaae
feat(capability): let a capability declare fallbacks, and give hide_k…
sarve-shreyas Oct 22, 2026
2d83947
Merge remote-tracking branch 'origin/main' into infra-capability
sarve-shreyas Oct 22, 2026
34629d5
fix(capability): publish set_window_rect and maximize_window, which E…
sarve-shreyas Oct 22, 2026
c570283
revert(capability): keep set_window_rect and maximize_window withheld
sarve-shreyas Oct 22, 2026
bccf477
fix(live): say the Live tools are manual, and name the session that c…
sarve-shreyas Oct 22, 2026
113007f
fix(capability): say that scroll_gesture and scroll_view are the swipe
sarve-shreyas Oct 22, 2026
454fbb5
feat(capability): read the screen an element at a time on mobile, and…
sarve-shreyas Oct 23, 2026
aab8500
fix(capability): on the user's own machine, open the live view for them
sarve-shreyas Oct 23, 2026
c79cd01
fix(capability): clear_element holds on every browser; property reads…
sarve-shreyas Oct 23, 2026
c23ca03
fix(capability): send_keys names the iOS read-back too
sarve-shreyas Oct 23, 2026
d30f9bf
fix(capability): say that App Live's app store is not App Automate's
sarve-shreyas Oct 23, 2026
8a3f982
fix(capability): route only on words a published capability answers
sarve-shreyas Oct 23, 2026
2dd5a16
feat(capability): ask the routing question in the user's words
sarve-shreyas Oct 23, 2026
04e267f
fix(capability): a product clash is a question for the user, not an e…
sarve-shreyas Oct 23, 2026
bb1a578
test(capability): read the clash message from its new field
sarve-shreyas Oct 23, 2026
a349c75
test(capability): the mismatch message names the product by title too
sarve-shreyas Oct 23, 2026
39a71d6
fix(capability): the mismatch message names both products by title
sarve-shreyas Oct 23, 2026
c3f6965
revert(capability): take the clash-as-question change out of this PR
sarve-shreyas Oct 23, 2026
8190272
fix(capability): stop naming App Automate and Automate in the infra p…
sarve-shreyas Oct 23, 2026
e09a285
Merge remote-tracking branch 'origin/main' into infra-capability
sarve-shreyas Oct 27, 2026
87f4968
fix(tools): keep the three grown descriptions inside main's budget
sarve-shreyas Oct 27, 2026
72dc024
fix(capability): address the review of #491 — pre-flight select, hone…
sarve-shreyas Oct 27, 2026
5a77730
fix(capability): a reply cut off mid-read says the request was sent
sarve-shreyas Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,227 changes: 2,227 additions & 0 deletions capability/devicecontrol.capability-index.json

Large diffs are not rendered by default.

3,782 changes: 3,782 additions & 0 deletions capability/mobiledevicecontrol.capability-index.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@browserstack/mcp-server",
"version": "2.2.8",
"version": "2.3.0",
"description": "BrowserStack's Official MCP Server",
"mcpName": "io.github.browserstack/mcp-server",
"main": "dist/index.js",
Expand Down
28 changes: 28 additions & 0 deletions scripts/check-contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,36 @@
"""
import json, sys, collections

# The envelope is three fields and every shipped index gets them right, so nothing checked
# it — until a hand-authored file arrived with `schema_version: "0.1"` and the loader
# refused it. Loading is a MERGE across files, so that one file took every product down
# with it: tm, tra and loadtesting became unavailable because a new index had a typo. Cheap
# to assert, and the failure it prevents is total.
SCHEMA_VERSION = 1


def check_envelope(path, d):
problems = []
if d.get('schema_version') != SCHEMA_VERSION:
problems.append(
f"schema_version is {json.dumps(d.get('schema_version'))}, and the server reads "
f"{SCHEMA_VERSION} — it will refuse this file AND every other index alongside it")
for field in ('version', 'build_id'):
if not isinstance(d.get(field), str) or not d[field].strip():
problems.append(f"{field} must be a non-empty string, and is {json.dumps(d.get(field))}")
products = [k for k in d if k not in ('schema_version', 'version', 'build_id')]
if len(products) != 1:
problems.append(f"expected exactly one product key, found {products}")
if problems:
print(f"\n{path}: unloadable envelope")
for problem in problems:
print(f" {problem}")
sys.exit(1)


def load(p):
d = json.load(open(p))
check_envelope(p, d)
return d, d[list(k for k in d if k not in ('schema_version','version','build_id'))[0]]

def resolve(tm, node, seen=None, path=''):
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/browserstack/mcp-server",
"source": "github"
},
"version": "2.2.8",
"version": "2.3.0",
"packages": [
{
"registryType": "npm",
"identifier": "@browserstack/mcp-server",
"version": "2.2.8",
"version": "2.3.0",
"transport": {
"type": "stdio"
},
Expand Down
13 changes: 12 additions & 1 deletion src/tools/applive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { startSession } from "./applive-utils/start-session.js";
import logger from "../logger.js";
import { trackMCP } from "../lib/instrumentation.js";
import { BrowserStackConfig } from "../lib/types.js";
import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js";

/**
* Launches an App Live Session on BrowserStack.
Expand Down Expand Up @@ -81,9 +82,19 @@ export default function addAppLiveTools(
) {
const tools: Record<string, any> = {};

// A Live session is for a PERSON. The agent cannot tap, type, read the screen or take a
// screenshot in it, and every eval where an agent was asked to operate an app and chose
// this tool ended with "my tools can start it but can't tap anything in it". The
// description says so, and names the surface that can — only when that surface is on.
const description =
"Open a manual App Live session for the user to operate by hand; the agent cannot drive it." +
(capabilityRegistryEnabled()
? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability."
: "");

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.",
description,
{
desiredPhone: z
.string()
Expand Down
72 changes: 72 additions & 0 deletions src/tools/capability-registry/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
*/

import appConfig from "../../config.js";
import {
APP_BINARY_EXTENSIONS,
MAX_APP_UPLOAD_BYTES,
validateUploadPath,
} from "../../lib/upload-validator.js";
import { InvocationError } from "./index-loader.js";
import { Capability, WireConstraints, WireParam } from "./types.js";

Expand Down Expand Up @@ -243,6 +249,44 @@ function coerceType(
label = param.name,
): unknown {
const expected = param.type;
if (expected === "any") {
// PASSES THROUGH UNTOUCHED, which is the whole point of declaring it. A polymorphic
// parameter reached the string branch and arrived as text: WebDriver's frame id takes a
// number, an element reference or null, and `null` sent as "null" is refused as an
// invalid argument — a failure that reads like the caller chose the wrong frame.
return value;
}
if (expected === "file") {
// NEVER ON THE HOSTED SERVER. `local_only` is what keeps file capabilities off the
// remote surface, and that is a flag an index author has to remember; this is the net
// under it. A hosted server has no business reading its own disk on a caller's behalf,
// whatever the index says.
if (appConfig.REMOTE_MCP)
throw new InvocationError(
`'${label}' is a file on the caller's own machine, which this server cannot read. ` +
"Use the form of this capability that takes a public link, or upload by hand.",
);
// CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path
// that does not exist — or a base directory nobody configured — is refused before the
// user is asked to approve an upload that was never going to happen.
//
// The rules are the ones the server already applies to every other upload, so the
// answer does not depend on which surface asked.
const given = String(value ?? "").trim();
if (!given)
throw new InvocationError(`'${label}' must be a path to a file`);
try {
return validateUploadPath(given, {
allowedExtensions: APP_BINARY_EXTENSIONS,
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
});
} catch (error) {
throw new InvocationError(
error instanceof Error ? error.message : String(error),
);
}
}
if (expected === "object" || expected === "array") {
// An opaque body object is passed through as given: the spec does not describe its
// fields, so validating or reshaping it would mean inventing a contract.
Expand Down Expand Up @@ -312,6 +356,34 @@ function coerceType(
return value;
}

// A DECLARED STRING IS NOT A LICENCE TO STRINGIFY ANYTHING.
//
// The untyped case above was fixed because `String(value)` destroyed objects the spec had
// declined to describe. A DECLARED string was left stringifying whatever arrived, and it
// is the worse of the two: an object sent for `create_test_case_comment`'s `comment` was
// serialised to the literal text "[object Object]", the API accepted it, the write
// returned 200, and the agent reported success in good faith. tm exposes no comment
// delete, so that text is now permanently on the case.
//
// A 400 teaches the caller what it got wrong. A 200 that stores "[object Object]" teaches
// it nothing and cannot be undone, so refuse here rather than let it reach the wire.
//
// `null` passes through instead of becoming the five characters "null", which no API has
// ever wanted: several string parameters document null as the way to CLEAR a field —
// `test_plan_id` says "send null or '' to unlink" — and stringifying it silently sent a
// value where the caller meant to send an absence.
if (expected === "string") {
if (value === null || value === undefined) return value;
if (typeof value === "object") {
throw new InvocationError(
`'${label}' must be a string, and was ${Array.isArray(value) ? "a list" : "an object"}. ` +
`Serialising it would send the text ` +
`${JSON.stringify(Array.isArray(value) ? "[object Array]" : "[object Object]")} ` +
`rather than its contents, and the call would succeed with that stored.`,
);
}
}

const text = String(value);
if (param.values && param.values.length > 0) {
const allowed = param.values.map((v) => String(v));
Expand Down
52 changes: 52 additions & 0 deletions src/tools/capability-registry/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,68 @@ function credentialsFrom(config: BrowserStackConfig): Credentials {
* Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
* that reads like the caller's problem, when it is our missing configuration.
*/
/**
* The host a capability named, as opposed to the product's own.
*
* `_HOST_` is in the override variable deliberately. The environment-suffixed form is
* `CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV>`, so a bare
* `CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HUB` would mean the hub host or the "hub"
* environment depending on which the reader had in mind — and whichever the code chose,
* the other reading sends a call to the wrong origin silently.
*
* A named environment refuses rather than falling back to the declared host, for the same
* reason the product-level resolver does: a preprod session must not reach production
* because nobody defined preprod's hub.
*/
function resolveNamedHost(
product: string,
key: string,
host: string,
source: HostSource | undefined,
environment: string | undefined,
): string {
const trim = (url: string) => url.replace(/\/$/, "");
const suffix = `${key}_HOST_${host.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${suffix}`];
if (override) return trim(override);

if (environment) {
throw new InvocationError(
`environment '${environment}' has no '${host}' host for product '${product}'. Set ` +
`CAPABILITY_REGISTRY_BASE_URL_${suffix}.`,
);
}

const declared = source?.hosts?.[host];
if (declared) return trim(declared);

const known = Object.keys(source?.hosts ?? {});
throw new InvocationError(
`that capability is served from the '${host}' host, which product '${product}' does ` +
`not declare. ` +
(known.length
? `Its index defines: ${known.join(", ")}.`
: `Its index defines no named hosts.`) +
` Set CAPABILITY_REGISTRY_BASE_URL_${suffix} to override.`,
);
}

export async function resolveBaseUrl(
product: string,
config: BrowserStackConfig,
source?: HostSource,
transport?: Transport,
host?: string,
): Promise<string> {
const key = product.toUpperCase();
const environment = selectedEnvironment();
const trim = (url: string) => url.replace(/\/$/, "");

// A capability that names a host is asking for one fixed origin, so none of the rungs
// below apply: discovery answers with the product's own host, and the declared
// `base_url` cannot serve this capability's paths at all.
if (host) return resolveNamedHost(product, key, host, source, environment);

const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`];
if (explicit) return trim(explicit);

Expand Down
2 changes: 1 addition & 1 deletion src/tools/capability-registry/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { Capability, ProductIndex } from "./types.js";
/** What the resolver needs from a product's index to find its host. */
export type HostSource = Pick<
ProductIndex,
"base_url" | "base_urls" | "probe_path" | "auth"
"base_url" | "base_urls" | "hosts" | "probe_path" | "auth"
> & {
capabilities?: Capability[];
};
Expand Down
Loading
Loading