@browserstack/mcp-server 1.3.1 → 1.4.0-beta.1
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/dist/index.js +5 -2
- package/dist/server-factory.js +5 -0
- package/dist/tools/accessibility.js +5 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +114 -0
- package/dist/tools/ask-browserstack/central-oauth.js +271 -0
- package/dist/tools/ask-browserstack/config.d.ts +96 -0
- package/dist/tools/ask-browserstack/config.js +134 -0
- package/dist/tools/ask-browserstack/egress.d.ts +34 -0
- package/dist/tools/ask-browserstack/egress.js +31 -0
- package/dist/tools/ask-browserstack/register.d.ts +61 -0
- package/dist/tools/ask-browserstack/register.js +394 -0
- package/dist/tools/ask-browserstack/relay.d.ts +201 -0
- package/dist/tools/ask-browserstack/relay.js +575 -0
- package/dist/tools/ask-browserstack/stream.d.ts +116 -0
- package/dist/tools/ask-browserstack/stream.js +237 -0
- package/dist/tools/ask-browserstack/types.d.ts +196 -0
- package/dist/tools/ask-browserstack/types.js +10 -0
- package/dist/tools/get-failure-logs.js +3 -1
- package/dist/tools/rca-agent.js +5 -2
- package/dist/tools/selfheal.js +5 -2
- package/dist/tools/testmanagement.js +33 -15
- package/dist/tools/tool-handoff.d.ts +37 -0
- package/dist/tools/tool-handoff.js +47 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,9 +29,12 @@ async function main() {
|
|
|
29
29
|
await mcpServer.getInstance().connect(transport);
|
|
30
30
|
}
|
|
31
31
|
main().catch(console.error);
|
|
32
|
-
// Ensure logs are flushed before exit
|
|
32
|
+
// Ensure logs are flushed before exit. Optional-called because an exit handler must not
|
|
33
|
+
// be the thing that throws: `flush` is absent from every `vi.mock("src/logger")` in the
|
|
34
|
+
// suite, so any test file that loads this module used to fail the run with
|
|
35
|
+
// `default.flush is not a function` depending on how vitest scheduled workers.
|
|
33
36
|
process.on("exit", () => {
|
|
34
|
-
logger.flush();
|
|
37
|
+
logger.flush?.();
|
|
35
38
|
});
|
|
36
39
|
export { setLogger } from "./logger.js";
|
|
37
40
|
export { BrowserStackMcpServer } from "./server-factory.js";
|
package/dist/server-factory.js
CHANGED
|
@@ -16,6 +16,7 @@ import addAppLiveTools from "./tools/applive.js";
|
|
|
16
16
|
import addBuildInsightsTools from "./tools/build-insights.js";
|
|
17
17
|
import { setupOnInitialized } from "./oninitialized.js";
|
|
18
18
|
import addRCATools from "./tools/rca-agent.js";
|
|
19
|
+
import addAskBrowserstackAITool from "./tools/ask-browserstack/register.js";
|
|
19
20
|
/**
|
|
20
21
|
* Wrapper class for BrowserStack MCP Server
|
|
21
22
|
* Stores a map of registered tools by name
|
|
@@ -51,6 +52,10 @@ export class BrowserStackMcpServer {
|
|
|
51
52
|
addSelfHealTools,
|
|
52
53
|
addBuildInsightsTools,
|
|
53
54
|
addRCATools,
|
|
55
|
+
// Hands a plain-language task to BrowserStack's agent and relays its mid-run
|
|
56
|
+
// permission asks back to this client, so a write can be confirmed by the human
|
|
57
|
+
// sitting in front of it rather than refused for want of anyone to ask.
|
|
58
|
+
addAskBrowserstackAITool,
|
|
54
59
|
];
|
|
55
60
|
toolAdders.forEach((adder) => {
|
|
56
61
|
// Each adder now returns a Record<string, Tool>
|
|
@@ -8,6 +8,7 @@ import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js
|
|
|
8
8
|
import { getBrowserStackAuth } from "../lib/get-auth.js";
|
|
9
9
|
import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
|
|
10
10
|
import logger from "../logger.js";
|
|
11
|
+
import { NEEDS_A11Y_CONFIG_ID, NEEDS_A11Y_SCAN_ID } from "./tool-handoff.js";
|
|
11
12
|
function setupAuth(config) {
|
|
12
13
|
const authString = getBrowserStackAuth(config);
|
|
13
14
|
const [username, password] = authString.split(":");
|
|
@@ -303,7 +304,8 @@ export default function addAccessibilityTools(server, config) {
|
|
|
303
304
|
return handleMCPError("createAccessibilityAuthConfig", server, config, error);
|
|
304
305
|
}
|
|
305
306
|
});
|
|
306
|
-
tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID."
|
|
307
|
+
tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID." +
|
|
308
|
+
NEEDS_A11Y_CONFIG_ID, {
|
|
307
309
|
configId: z.number().describe("ID of the auth configuration to retrieve"),
|
|
308
310
|
}, {
|
|
309
311
|
title: "Get Accessibility Auth Config",
|
|
@@ -314,7 +316,8 @@ export default function addAccessibilityTools(server, config) {
|
|
|
314
316
|
}, async (args) => {
|
|
315
317
|
return await executeGetAuthConfig(args, server, config);
|
|
316
318
|
});
|
|
317
|
-
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results."
|
|
319
|
+
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results." +
|
|
320
|
+
NEEDS_A11Y_SCAN_ID, {
|
|
318
321
|
scanId: z
|
|
319
322
|
.string()
|
|
320
323
|
.describe("The scan ID from a completed accessibility scan"),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
3
|
+
*
|
|
4
|
+
* This replaces a shared delegation token, and the upgrade is not cosmetic.
|
|
5
|
+
* `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
|
|
6
|
+
* what we mint here is USER-ATTESTED: Atlas sets `principal_verified=True`, takes the acting
|
|
7
|
+
* user from signed claims rather than from anything we put in the request body, and reuses
|
|
8
|
+
* this same JWT as its `egress_token` — so the product call a human approves runs as that
|
|
9
|
+
* human, not as a shared service account.
|
|
10
|
+
*
|
|
11
|
+
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
12
|
+
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
13
|
+
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
14
|
+
* logged, returned, or put in an error message. Only a status code is.
|
|
15
|
+
*/
|
|
16
|
+
import { Credentials } from "./egress.js";
|
|
17
|
+
/**
|
|
18
|
+
* BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
|
|
19
|
+
*
|
|
20
|
+
* `oauth_user_profile` stays because it is what makes the pair obtainable through the
|
|
21
|
+
* username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
|
|
22
|
+
* (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
|
|
23
|
+
* `web/oauth.py`); both halves move together with Atlas.
|
|
24
|
+
*
|
|
25
|
+
* THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
|
|
26
|
+
* discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
|
|
27
|
+
*
|
|
28
|
+
* - `ai_agent_notify` is documented there as CLIENT_ID/SECRET auth, and
|
|
29
|
+
* `USERNAME_ACCESS_KEY_ONLY_SCOPES` remains only `user_management, oauth_user_profile`.
|
|
30
|
+
* We are on the username+access_key flow, which those restrictions are not written for.
|
|
31
|
+
* - It is additionally covered by a new
|
|
32
|
+
* `APP_REGISTERED_SCOPE_REQUIRED = %w[ai_agent ai_agent_notify]` gate, requiring the
|
|
33
|
+
* calling APPLICATION to be registered for it — though that gate sits in the
|
|
34
|
+
* `client_id + client_secret` path, not ours.
|
|
35
|
+
* - railsApp defines it as the PRODUCT -> AGENT direction: "a product reporting progress
|
|
36
|
+
* back to an AI agent for work the agent dispatched." We use it in the opposite
|
|
37
|
+
* direction, as an agent -> Atlas inbound credential.
|
|
38
|
+
* - `central_ai_s2s`, which this replaces, was deliberately EXCLUDED from that new gate.
|
|
39
|
+
*
|
|
40
|
+
* So this is strictly more restricted than what it replaces. If the endpoint refuses it, that
|
|
41
|
+
* is a PROVISIONING problem — the scope is not available to this credential type or this
|
|
42
|
+
* application — and it is reported as one, naming the scope. It is never retried with a
|
|
43
|
+
* different scope: a silent downgrade to a different authorization is exactly the kind of
|
|
44
|
+
* thing nobody notices until it matters.
|
|
45
|
+
*/
|
|
46
|
+
export declare const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
47
|
+
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
48
|
+
export declare const REQUESTED_EXPIRES_IN = 3600;
|
|
49
|
+
/**
|
|
50
|
+
* Treat a token as stale this long before it actually expires.
|
|
51
|
+
*
|
|
52
|
+
* NOT the usual small skew. This token is not merely used to open the request — Atlas holds
|
|
53
|
+
* it for the life of the run and re-uses it for product egress, so it has to outlive the
|
|
54
|
+
* whole call, and our own `/agent` budget is already 330s. Handing out a token with 61
|
|
55
|
+
* seconds left would mean a human approves a write and the egress that follows fails on an
|
|
56
|
+
* expired credential, which is the exact mid-flight expiry this cache exists to prevent.
|
|
57
|
+
*/
|
|
58
|
+
export declare const REFRESH_SKEW_MS: number;
|
|
59
|
+
/** The token endpoint gets its own, much shorter budget than `/agent`. */
|
|
60
|
+
export declare const TOKEN_TIMEOUT_MS = 15000;
|
|
61
|
+
export interface TokenResponse {
|
|
62
|
+
status: number;
|
|
63
|
+
body: unknown;
|
|
64
|
+
/** Only when there was no response at all to speak for itself. */
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
export type TokenTransport = (url: string, form: Record<string, string>) => Promise<TokenResponse>;
|
|
68
|
+
/**
|
|
69
|
+
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
70
|
+
*
|
|
71
|
+
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
72
|
+
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
73
|
+
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
74
|
+
* only really be about the scope.
|
|
75
|
+
*
|
|
76
|
+
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
77
|
+
*/
|
|
78
|
+
export declare function refusalIsAboutScope(status: number, body: unknown): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The ways authentication can fail, kept apart because a user cannot act on them otherwise.
|
|
81
|
+
*
|
|
82
|
+
* `scope refused` is a provisioning problem; `rejected` is "your credentials are wrong";
|
|
83
|
+
* `unreachable` is "auth is down". A fourth — Atlas refusing a token we minted successfully —
|
|
84
|
+
* is a server misconfiguration and lives in `relay.ts`, because it is discovered from
|
|
85
|
+
* `/agent`. Four different fixes, so four different sentences.
|
|
86
|
+
*/
|
|
87
|
+
export declare const AUTH_SCOPE_REFUSED_DETAIL: (status: number) => string;
|
|
88
|
+
export declare const AUTH_REJECTED_DETAIL: (status: number) => string;
|
|
89
|
+
export declare const AUTH_UNREACHABLE_DETAIL: string;
|
|
90
|
+
/**
|
|
91
|
+
* A 5xx from auth: their service is down, not your password.
|
|
92
|
+
*
|
|
93
|
+
* Split out because routing 5xx to `AUTH_REJECTED_DETAIL` actively misdirects the reader,
|
|
94
|
+
* and did: a preprod outage returned 503 and the tool answered "Your BrowserStack
|
|
95
|
+
* credentials were rejected … Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY",
|
|
96
|
+
* sending someone to audit env vars that had worked minutes earlier. The status alone
|
|
97
|
+
* settles it — OAuth2 says a bad client is 401/403 and a bad request is 400, so nothing in
|
|
98
|
+
* the 5xx range is ever a statement about the caller.
|
|
99
|
+
*/
|
|
100
|
+
export declare const AUTH_SERVER_ERROR_DETAIL: (status: number) => string;
|
|
101
|
+
export declare const AUTH_UNUSABLE_DETAIL: (status: number) => string;
|
|
102
|
+
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
103
|
+
export declare function resetTokenCache(): void;
|
|
104
|
+
/** A fetch-based transport for the token endpoint. */
|
|
105
|
+
export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
|
|
106
|
+
/** The exact form body of the `client_credentials` grant. */
|
|
107
|
+
export declare function mintForm(credentials: Credentials): Record<string, string>;
|
|
108
|
+
/**
|
|
109
|
+
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
110
|
+
*
|
|
111
|
+
* Minting per tool call would add a round trip to every request and make the token endpoint
|
|
112
|
+
* a hot dependency of the whole surface.
|
|
113
|
+
*/
|
|
114
|
+
export declare function mintCentralToken(url: string, credentials: Credentials, transport: TokenTransport, now?: number): Promise<string>;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
3
|
+
*
|
|
4
|
+
* This replaces a shared delegation token, and the upgrade is not cosmetic.
|
|
5
|
+
* `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
|
|
6
|
+
* what we mint here is USER-ATTESTED: Atlas sets `principal_verified=True`, takes the acting
|
|
7
|
+
* user from signed claims rather than from anything we put in the request body, and reuses
|
|
8
|
+
* this same JWT as its `egress_token` — so the product call a human approves runs as that
|
|
9
|
+
* human, not as a shared service account.
|
|
10
|
+
*
|
|
11
|
+
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
12
|
+
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
13
|
+
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
14
|
+
* logged, returned, or put in an error message. Only a status code is.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import logger from "../../logger.js";
|
|
18
|
+
import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
|
|
19
|
+
/**
|
|
20
|
+
* BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
|
|
21
|
+
*
|
|
22
|
+
* `oauth_user_profile` stays because it is what makes the pair obtainable through the
|
|
23
|
+
* username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
|
|
24
|
+
* (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
|
|
25
|
+
* `web/oauth.py`); both halves move together with Atlas.
|
|
26
|
+
*
|
|
27
|
+
* THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
|
|
28
|
+
* discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
|
|
29
|
+
*
|
|
30
|
+
* - `ai_agent_notify` is documented there as CLIENT_ID/SECRET auth, and
|
|
31
|
+
* `USERNAME_ACCESS_KEY_ONLY_SCOPES` remains only `user_management, oauth_user_profile`.
|
|
32
|
+
* We are on the username+access_key flow, which those restrictions are not written for.
|
|
33
|
+
* - It is additionally covered by a new
|
|
34
|
+
* `APP_REGISTERED_SCOPE_REQUIRED = %w[ai_agent ai_agent_notify]` gate, requiring the
|
|
35
|
+
* calling APPLICATION to be registered for it — though that gate sits in the
|
|
36
|
+
* `client_id + client_secret` path, not ours.
|
|
37
|
+
* - railsApp defines it as the PRODUCT -> AGENT direction: "a product reporting progress
|
|
38
|
+
* back to an AI agent for work the agent dispatched." We use it in the opposite
|
|
39
|
+
* direction, as an agent -> Atlas inbound credential.
|
|
40
|
+
* - `central_ai_s2s`, which this replaces, was deliberately EXCLUDED from that new gate.
|
|
41
|
+
*
|
|
42
|
+
* So this is strictly more restricted than what it replaces. If the endpoint refuses it, that
|
|
43
|
+
* is a PROVISIONING problem — the scope is not available to this credential type or this
|
|
44
|
+
* application — and it is reported as one, naming the scope. It is never retried with a
|
|
45
|
+
* different scope: a silent downgrade to a different authorization is exactly the kind of
|
|
46
|
+
* thing nobody notices until it matters.
|
|
47
|
+
*/
|
|
48
|
+
export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
49
|
+
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
50
|
+
export const REQUESTED_EXPIRES_IN = 3600;
|
|
51
|
+
/**
|
|
52
|
+
* Treat a token as stale this long before it actually expires.
|
|
53
|
+
*
|
|
54
|
+
* NOT the usual small skew. This token is not merely used to open the request — Atlas holds
|
|
55
|
+
* it for the life of the run and re-uses it for product egress, so it has to outlive the
|
|
56
|
+
* whole call, and our own `/agent` budget is already 330s. Handing out a token with 61
|
|
57
|
+
* seconds left would mean a human approves a write and the egress that follows fails on an
|
|
58
|
+
* expired credential, which is the exact mid-flight expiry this cache exists to prevent.
|
|
59
|
+
*/
|
|
60
|
+
export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000;
|
|
61
|
+
/** The token endpoint gets its own, much shorter budget than `/agent`. */
|
|
62
|
+
export const TOKEN_TIMEOUT_MS = 15_000;
|
|
63
|
+
/**
|
|
64
|
+
* The OAuth2 error codes we are willing to read out of a failure body.
|
|
65
|
+
*
|
|
66
|
+
* `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description`
|
|
67
|
+
* is free text and demonstrably CAN ("access_key <key> is invalid"), which is why only the
|
|
68
|
+
* code is ever looked at and only when it is one of these. Anything else is ignored entirely
|
|
69
|
+
* and the classification falls back to the status.
|
|
70
|
+
*/
|
|
71
|
+
const SCOPE_ERROR_CODES = [
|
|
72
|
+
"invalid_scope",
|
|
73
|
+
"unauthorized_client",
|
|
74
|
+
"invalid_request",
|
|
75
|
+
];
|
|
76
|
+
const CREDENTIAL_ERROR_CODES = [
|
|
77
|
+
"invalid_client",
|
|
78
|
+
"invalid_grant",
|
|
79
|
+
"access_denied",
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
83
|
+
*
|
|
84
|
+
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
85
|
+
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
86
|
+
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
87
|
+
* only really be about the scope.
|
|
88
|
+
*
|
|
89
|
+
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
90
|
+
*/
|
|
91
|
+
export function refusalIsAboutScope(status, body) {
|
|
92
|
+
const payload = typeof body === "object" && body !== null
|
|
93
|
+
? body
|
|
94
|
+
: {};
|
|
95
|
+
const code = typeof payload.error === "string" ? payload.error : "";
|
|
96
|
+
if (SCOPE_ERROR_CODES.includes(code))
|
|
97
|
+
return true;
|
|
98
|
+
if (CREDENTIAL_ERROR_CODES.includes(code))
|
|
99
|
+
return false;
|
|
100
|
+
// No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
|
|
101
|
+
// the status is the next best evidence.
|
|
102
|
+
return status === 400;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The ways authentication can fail, kept apart because a user cannot act on them otherwise.
|
|
106
|
+
*
|
|
107
|
+
* `scope refused` is a provisioning problem; `rejected` is "your credentials are wrong";
|
|
108
|
+
* `unreachable` is "auth is down". A fourth — Atlas refusing a token we minted successfully —
|
|
109
|
+
* is a server misconfiguration and lives in `relay.ts`, because it is discovered from
|
|
110
|
+
* `/agent`. Four different fixes, so four different sentences.
|
|
111
|
+
*/
|
|
112
|
+
export const AUTH_SCOPE_REFUSED_DETAIL = (status) => `BrowserStack auth would not issue a token for the scope "${CENTRAL_SCOPE}" (HTTP ${status}). ` +
|
|
113
|
+
`YOUR CREDENTIALS ARE NOT THE PROBLEM — this is a provisioning problem: \`ai_agent_notify\` ` +
|
|
114
|
+
`is documented as a client_id/secret scope, it is not in the username+access_key allow ` +
|
|
115
|
+
`list, and it carries an application-registration requirement. It has to be enabled for ` +
|
|
116
|
+
`this account or application; a different password will not help, and this server will ` +
|
|
117
|
+
`NOT quietly retry with a weaker scope. NOTHING REACHED THE AGENT — no request was made, ` +
|
|
118
|
+
`no prompt appeared and nothing was changed.`;
|
|
119
|
+
export const AUTH_REJECTED_DETAIL = (status) => `Your BrowserStack credentials were rejected by BrowserStack auth (HTTP ${status}). ` +
|
|
120
|
+
`NOTHING REACHED THE AGENT — no request was made, no prompt appeared and nothing was ` +
|
|
121
|
+
`changed. Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.`;
|
|
122
|
+
export const AUTH_UNREACHABLE_DETAIL = "Could not reach BrowserStack auth to sign in. NOTHING REACHED THE AGENT — no request " +
|
|
123
|
+
"was made, no prompt appeared and nothing was changed. This is a connectivity or " +
|
|
124
|
+
"auth-server problem, not a problem with your credentials.";
|
|
125
|
+
/**
|
|
126
|
+
* A 5xx from auth: their service is down, not your password.
|
|
127
|
+
*
|
|
128
|
+
* Split out because routing 5xx to `AUTH_REJECTED_DETAIL` actively misdirects the reader,
|
|
129
|
+
* and did: a preprod outage returned 503 and the tool answered "Your BrowserStack
|
|
130
|
+
* credentials were rejected … Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY",
|
|
131
|
+
* sending someone to audit env vars that had worked minutes earlier. The status alone
|
|
132
|
+
* settles it — OAuth2 says a bad client is 401/403 and a bad request is 400, so nothing in
|
|
133
|
+
* the 5xx range is ever a statement about the caller.
|
|
134
|
+
*/
|
|
135
|
+
export const AUTH_SERVER_ERROR_DETAIL = (status) => `BrowserStack auth is unavailable (HTTP ${status}). YOUR CREDENTIALS ARE NOT THE ` +
|
|
136
|
+
`PROBLEM — a 5xx is the auth service failing, not a rejection, so there is nothing to ` +
|
|
137
|
+
`change on your side and nothing to retry differently. NOTHING REACHED THE AGENT — no ` +
|
|
138
|
+
`request was made, no prompt appeared and nothing was changed. Try again once ` +
|
|
139
|
+
`BrowserStack auth is back.`;
|
|
140
|
+
export const AUTH_UNUSABLE_DETAIL = (status) => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` +
|
|
141
|
+
`AGENT — no request was made, no prompt appeared and nothing was changed.`;
|
|
142
|
+
const cache = new Map();
|
|
143
|
+
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
144
|
+
export function resetTokenCache() {
|
|
145
|
+
cache.clear();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The cache key.
|
|
149
|
+
*
|
|
150
|
+
* Keyed on the access key so that ROTATING it mints immediately rather than leaving a
|
|
151
|
+
* revoked credential working until expiry — but on a SHA-256 of it, never the value, so the
|
|
152
|
+
* secret is not left sitting in a map key for the life of the process.
|
|
153
|
+
*/
|
|
154
|
+
function cacheKey(url, credentials) {
|
|
155
|
+
const digest = createHash("sha256")
|
|
156
|
+
.update(credentials.accessKey)
|
|
157
|
+
.digest("hex");
|
|
158
|
+
return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
|
|
159
|
+
}
|
|
160
|
+
/** A fetch-based transport for the token endpoint. */
|
|
161
|
+
export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
|
|
162
|
+
return async (url, form) => {
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
170
|
+
Accept: "application/json",
|
|
171
|
+
},
|
|
172
|
+
body: new URLSearchParams(form).toString(),
|
|
173
|
+
redirect: "manual",
|
|
174
|
+
signal: controller.signal,
|
|
175
|
+
});
|
|
176
|
+
let parsed = null;
|
|
177
|
+
try {
|
|
178
|
+
parsed = await response.json();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// An HTML error page behind any status. The caller only reads the status.
|
|
182
|
+
parsed = null;
|
|
183
|
+
}
|
|
184
|
+
return { status: response.status, body: parsed };
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
|
|
188
|
+
// carried: it can name the URL and, on some stacks, echo the request body.
|
|
189
|
+
return { status: 0, body: null, error: "auth could not be reached" };
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The exact form body of the `client_credentials` grant. */
|
|
197
|
+
export function mintForm(credentials) {
|
|
198
|
+
return {
|
|
199
|
+
grant_type: "client_credentials",
|
|
200
|
+
username: credentials.username,
|
|
201
|
+
access_key: credentials.accessKey,
|
|
202
|
+
scope: CENTRAL_SCOPE,
|
|
203
|
+
expires_in: String(REQUESTED_EXPIRES_IN),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function mintOnce(url, credentials, transport) {
|
|
207
|
+
const response = await transport(url, mintForm(credentials));
|
|
208
|
+
if (response.status === 0)
|
|
209
|
+
throw new AskError(AUTH_UNREACHABLE_DETAIL);
|
|
210
|
+
// 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
|
|
211
|
+
// is worse than saying nothing — it names the caller's credentials as the fault.
|
|
212
|
+
if (response.status >= 500) {
|
|
213
|
+
throw new AskError(AUTH_SERVER_ERROR_DETAIL(response.status));
|
|
214
|
+
}
|
|
215
|
+
if (response.status !== 200) {
|
|
216
|
+
// ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
|
|
217
|
+
// credential one, and nothing out of it is ever put in the message — a non-200 body can
|
|
218
|
+
// echo the access key straight back.
|
|
219
|
+
throw new AskError(refusalIsAboutScope(response.status, response.body)
|
|
220
|
+
? AUTH_SCOPE_REFUSED_DETAIL(response.status)
|
|
221
|
+
: AUTH_REJECTED_DETAIL(response.status));
|
|
222
|
+
}
|
|
223
|
+
const body = typeof response.body === "object" && response.body !== null
|
|
224
|
+
? response.body
|
|
225
|
+
: {};
|
|
226
|
+
const token = body.access_token;
|
|
227
|
+
if (typeof token !== "string" || !token) {
|
|
228
|
+
throw new AskError(AUTH_UNUSABLE_DETAIL(response.status));
|
|
229
|
+
}
|
|
230
|
+
// Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
|
|
231
|
+
// caching for the requested hour when it granted less would hand out a dead token.
|
|
232
|
+
const granted = Number(body.expires_in);
|
|
233
|
+
const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
|
|
234
|
+
return { token, lifetimeMs: seconds * 1000 };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
238
|
+
*
|
|
239
|
+
* Minting per tool call would add a round trip to every request and make the token endpoint
|
|
240
|
+
* a hot dependency of the whole surface.
|
|
241
|
+
*/
|
|
242
|
+
export async function mintCentralToken(url, credentials, transport, now = Date.now()) {
|
|
243
|
+
// Refused before any network call, and by name: these ARE the auth credential now, not
|
|
244
|
+
// merely attribution, so an empty one is our missing configuration rather than the user's
|
|
245
|
+
// rejected password, and must not read like one.
|
|
246
|
+
if (!credentials?.username || !credentials?.accessKey) {
|
|
247
|
+
throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
|
|
248
|
+
"BROWSERSTACK_ACCESS_KEY are required to sign in");
|
|
249
|
+
}
|
|
250
|
+
const key = cacheKey(url, credentials);
|
|
251
|
+
const entry = cache.get(key);
|
|
252
|
+
if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
|
|
253
|
+
return entry.token;
|
|
254
|
+
}
|
|
255
|
+
// Double-checked through a shared promise: concurrent callers await the same mint.
|
|
256
|
+
if (entry?.inflight)
|
|
257
|
+
return entry.inflight;
|
|
258
|
+
const pending = mintOnce(url, credentials, transport)
|
|
259
|
+
.then(({ token, lifetimeMs }) => {
|
|
260
|
+
cache.set(key, { token, expiresAt: now + lifetimeMs });
|
|
261
|
+
logger.info("askBrowserstackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
|
|
262
|
+
return token;
|
|
263
|
+
})
|
|
264
|
+
.catch((error) => {
|
|
265
|
+
// Never leave a rejected promise cached, or every later call inherits this failure.
|
|
266
|
+
cache.delete(key);
|
|
267
|
+
throw error;
|
|
268
|
+
});
|
|
269
|
+
cache.set(key, { token: "", expiresAt: 0, inflight: pending });
|
|
270
|
+
return pending;
|
|
271
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Atlas lives, and the timeout ladder.
|
|
3
|
+
*
|
|
4
|
+
* The host IS compiled in, matching every other tool here — `TM_BASE_URLS`, the
|
|
5
|
+
* instrumentation endpoint — so an install needs no configuration to work. One env var
|
|
6
|
+
* overrides it. See the warning on `DEFAULT_ATLAS_URL`: the compiled-in value is currently
|
|
7
|
+
* STAGING and is a deliberate placeholder.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* CONTRACT §4 — the timeout ladder, outermost first. EACH LAYER MUST EXCEED THE ONE INSIDE
|
|
11
|
+
* IT, or a layer dies before the layer it is waiting on can answer:
|
|
12
|
+
*
|
|
13
|
+
* MCP client -> tool call longest, client-side, not ours
|
|
14
|
+
* POST /agent HTTP request 330s <- here
|
|
15
|
+
* Atlas gate -> stream ask 300s Atlas's `permission_relay_timeout`
|
|
16
|
+
* elicitInput 270s <- here
|
|
17
|
+
*
|
|
18
|
+
* 300s is the browser path's existing PERMISSION_TIMEOUT, which also auto-rejects.
|
|
19
|
+
*/
|
|
20
|
+
export declare const AGENT_TIMEOUT_MS = 330000;
|
|
21
|
+
export declare const ELICITATION_TIMEOUT_MS = 270000;
|
|
22
|
+
/** Thrown for anything this tool refuses to attempt. Never carries a credential. */
|
|
23
|
+
export declare class AskError extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** Off by default is wrong for a shipped feature, but a kill switch is not. */
|
|
26
|
+
export declare function isEnabled(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
|
|
29
|
+
*
|
|
30
|
+
* OFF BY DEFAULT, because it depends on something outside this package: the host has to
|
|
31
|
+
* keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
|
|
32
|
+
* and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
|
|
33
|
+
* that never asked anything, leaving the real one suspended until it times out. So this
|
|
34
|
+
* must stay opt-in per deployment rather than become a default that silently hangs.
|
|
35
|
+
*
|
|
36
|
+
* Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
|
|
37
|
+
* declared the `elicitation` capability, and a client that did not still gets a read-only
|
|
38
|
+
* run. This flag only removes the blanket refusal.
|
|
39
|
+
*/
|
|
40
|
+
export declare function allowRemoteRelay(): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* ============================================================================
|
|
43
|
+
* PRODUCTION DEFAULTS
|
|
44
|
+
* ============================================================================
|
|
45
|
+
*
|
|
46
|
+
* These hosts are PRODUCTION. They replace the interim staging placeholders that this
|
|
47
|
+
* package shipped with while the relay was being built ("for now lets hardcode the
|
|
48
|
+
* base_url to staging only then we will point this to prod url later") — that step is
|
|
49
|
+
* now done.
|
|
50
|
+
*
|
|
51
|
+
* `https://workflows.browserstack.com` was verified, not guessed: its `/api/profiles`
|
|
52
|
+
* answers `401 {"detail":"authentication required"}`, byte-identical to staging Atlas.
|
|
53
|
+
* The production auth endpoint is `https://auth.browserstack.com/oauth2/v2/token`.
|
|
54
|
+
*
|
|
55
|
+
* WHY THIS MATTERS: this package publishes to npm as `@browserstack/mcp-server`, so an
|
|
56
|
+
* install with no environment variables set now talks to PRODUCTION. That is correct for
|
|
57
|
+
* a production deployment, but it removes the old safety property — a misconfigured or
|
|
58
|
+
* test deployment that forgets `ASK_BROWSERSTACK_ATLAS_URL` no longer fails safe onto
|
|
59
|
+
* staging, it reads and writes REAL customer data. Non-production deployments MUST set
|
|
60
|
+
* that variable explicitly. The resolved host is logged at info on first use, naming
|
|
61
|
+
* whether it came from the env var or from here, so a deployment pointing at the wrong
|
|
62
|
+
* Atlas is visible in a log line rather than inferred later from confusing data.
|
|
63
|
+
*
|
|
64
|
+
* Staging hosts, for anyone setting the override:
|
|
65
|
+
* ASK_BROWSERSTACK_ATLAS_URL = https://ai-platform-service.bsstag.com
|
|
66
|
+
* ASK_BROWSERSTACK_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
67
|
+
*
|
|
68
|
+
* The tests assert these literals precisely so that repointing has to be deliberate
|
|
69
|
+
* rather than something that slips through.
|
|
70
|
+
*
|
|
71
|
+
* grep: DEFAULT-PROD-HOSTS
|
|
72
|
+
*/
|
|
73
|
+
export declare const DEFAULT_ATLAS_URL = "https://workflows.browserstack.com";
|
|
74
|
+
export declare const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
75
|
+
/** For tests, and for anything that legitimately re-resolves. */
|
|
76
|
+
export declare function resetHostAnnouncements(): void;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve Atlas's base URL:
|
|
79
|
+
*
|
|
80
|
+
* 1. ASK_BROWSERSTACK_ATLAS_URL explicit override
|
|
81
|
+
* 2. the built-in staging default (see the warning above)
|
|
82
|
+
*
|
|
83
|
+
* Matching every other tool here, which ships its host in the code and treats the env var as
|
|
84
|
+
* an override — `TM_BASE_URLS`, the instrumentation endpoint. There is no environment map and
|
|
85
|
+
* no selector: one default, one override.
|
|
86
|
+
*/
|
|
87
|
+
export declare function atlasBaseUrl(): string;
|
|
88
|
+
/** Resolved per call, never captured at construction. */
|
|
89
|
+
export declare function agentUrl(): string;
|
|
90
|
+
/**
|
|
91
|
+
* Where a central-OAuth JWT is minted (CONTRACT v1.2 §I, as amended by task 7).
|
|
92
|
+
*
|
|
93
|
+
* The shared `delegation.token` path is gone from Atlas, so a user-attested central JWT is
|
|
94
|
+
* the only way in. Same two rungs as the host, and the same staging default.
|
|
95
|
+
*/
|
|
96
|
+
export declare function authTokenUrl(): string;
|