@browserstack/mcp-server 1.4.0-beta.3 → 1.5.0-beta.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.
Files changed (46) hide show
  1. package/capability/loadtesting.capability-index.json +1764 -0
  2. package/capability/tm.capability-index.json +19793 -0
  3. package/dist/config.d.ts +1 -4
  4. package/dist/config.js +2 -23
  5. package/dist/index.js +2 -5
  6. package/dist/server-factory.js +5 -5
  7. package/dist/tools/accessibility.js +2 -5
  8. package/dist/tools/capability-registry/bind.d.ts +29 -0
  9. package/dist/tools/capability-registry/bind.js +134 -0
  10. package/dist/tools/capability-registry/config.d.ts +62 -0
  11. package/dist/tools/capability-registry/config.js +218 -0
  12. package/dist/tools/capability-registry/discovery.d.ts +44 -0
  13. package/dist/tools/capability-registry/discovery.js +99 -0
  14. package/dist/tools/capability-registry/egress.d.ts +44 -0
  15. package/dist/tools/capability-registry/egress.js +128 -0
  16. package/dist/tools/capability-registry/index-loader.d.ts +119 -0
  17. package/dist/tools/capability-registry/index-loader.js +314 -0
  18. package/dist/tools/capability-registry/register.d.ts +34 -0
  19. package/dist/tools/capability-registry/register.js +354 -0
  20. package/dist/tools/capability-registry/resolve.d.ts +38 -0
  21. package/dist/tools/capability-registry/resolve.js +45 -0
  22. package/dist/tools/capability-registry/search.d.ts +65 -0
  23. package/dist/tools/capability-registry/search.js +342 -0
  24. package/dist/tools/capability-registry/types.d.ts +208 -0
  25. package/dist/tools/capability-registry/types.js +33 -0
  26. package/dist/tools/get-failure-logs.js +1 -3
  27. package/dist/tools/rca-agent.js +2 -5
  28. package/dist/tools/selfheal.js +2 -5
  29. package/dist/tools/testmanagement.js +15 -37
  30. package/package.json +3 -2
  31. package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
  32. package/dist/tools/ask-browserstack/central-oauth.js +0 -277
  33. package/dist/tools/ask-browserstack/config.d.ts +0 -102
  34. package/dist/tools/ask-browserstack/config.js +0 -140
  35. package/dist/tools/ask-browserstack/egress.d.ts +0 -34
  36. package/dist/tools/ask-browserstack/egress.js +0 -31
  37. package/dist/tools/ask-browserstack/register.d.ts +0 -61
  38. package/dist/tools/ask-browserstack/register.js +0 -416
  39. package/dist/tools/ask-browserstack/relay.d.ts +0 -201
  40. package/dist/tools/ask-browserstack/relay.js +0 -577
  41. package/dist/tools/ask-browserstack/stream.d.ts +0 -116
  42. package/dist/tools/ask-browserstack/stream.js +0 -236
  43. package/dist/tools/ask-browserstack/types.d.ts +0 -196
  44. package/dist/tools/ask-browserstack/types.js +0 -14
  45. package/dist/tools/tool-handoff.d.ts +0 -62
  46. package/dist/tools/tool-handoff.js +0 -75
@@ -19,7 +19,6 @@ import { getTestPlan, GetTestPlanSchema, } from "./testmanagement-utils/get-test
19
19
  import { listSubTestPlans, ListSubTestPlansSchema, } from "./testmanagement-utils/list-sub-testplans.js";
20
20
  import { getSubTestPlan, GetSubTestPlanSchema, } from "./testmanagement-utils/get-sub-testplan.js";
21
21
  import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
22
- import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID, PLAN_WRITES_VIA_AGENT, PROJECT_ID_ONLY_FOR_FOLDER, } from "./tool-handoff.js";
23
22
  //TODO: Moving the traceMCP and catch block to the parent(server) function
24
23
  /**
25
24
  * Wrapper to call createProjectOrFolder util.
@@ -433,40 +432,35 @@ export async function getSubTestPlanTool(args, config, server) {
433
432
  */
434
433
  export default function addTestManagementTools(server, config) {
435
434
  const tools = {};
436
- tools.createProjectOrFolder = server.tool("createProjectOrFolder", "Create a project and/or folder in BrowserStack Test Management." +
437
- PROJECT_ID_ONLY_FOR_FOLDER, CreateProjFoldSchema.shape, {
435
+ tools.createProjectOrFolder = server.tool("createProjectOrFolder", "Create a project and/or folder in BrowserStack Test Management.", CreateProjFoldSchema.shape, {
438
436
  title: "Create Project or Folder",
439
437
  readOnlyHint: false,
440
438
  openWorldHint: false,
441
439
  destructiveHint: false,
442
440
  idempotentHint: false,
443
441
  }, (args) => createProjectOrFolderTool(args, config, server));
444
- tools.createTestCase = server.tool("createTestCase", "Use this tool to create a test case in BrowserStack Test Management." +
445
- NEEDS_PROJECT_ID, CreateTestCaseSchema.shape, {
442
+ tools.createTestCase = server.tool("createTestCase", "Use this tool to create a test case in BrowserStack Test Management.", CreateTestCaseSchema.shape, {
446
443
  title: "Create Test Case",
447
444
  readOnlyHint: false,
448
445
  openWorldHint: false,
449
446
  destructiveHint: false,
450
447
  idempotentHint: false,
451
448
  }, (args) => createTestCaseTool(args, config, server));
452
- tools.updateTestCase = server.tool("updateTestCase", "Update an existing test case in BrowserStack Test Management. Any subset of the following fields may be changed: name, description, preconditions, test_case_steps, owner, priority, case_type, automation_status, status, tags, issues, custom_fields. Only the supplied fields are modified." +
453
- NEEDS_PROJECT_ID, UpdateTestCaseSchema.shape, {
449
+ tools.updateTestCase = server.tool("updateTestCase", "Update an existing test case in BrowserStack Test Management. Any subset of the following fields may be changed: name, description, preconditions, test_case_steps, owner, priority, case_type, automation_status, status, tags, issues, custom_fields. Only the supplied fields are modified.", UpdateTestCaseSchema.shape, {
454
450
  title: "Update Test Case",
455
451
  readOnlyHint: false,
456
452
  openWorldHint: false,
457
453
  destructiveHint: true,
458
454
  idempotentHint: true,
459
455
  }, (args) => updateTestCaseTool(args, config, server));
460
- tools.listTestCases = server.tool("listTestCases", "List test cases in a project, optionally scoped to a specific folder. Omit folder_id to list all test cases in the project; provide folder_id (discoverable via listFolders) to list only that folder's cases. Supports filters: case_type, priority, pagination." +
461
- NEEDS_PROJECT_ID, ListTestCasesSchema.shape, {
456
+ tools.listTestCases = server.tool("listTestCases", "List test cases in a project, optionally scoped to a specific folder. Omit folder_id to list all test cases in the project; provide folder_id (discoverable via listFolders) to list only that folder's cases. Supports filters: case_type, priority, pagination.", ListTestCasesSchema.shape, {
462
457
  title: "List Test Cases",
463
458
  readOnlyHint: true,
464
459
  openWorldHint: false,
465
460
  destructiveHint: false,
466
461
  idempotentHint: true,
467
462
  }, (args) => listTestCasesTool(args, config, server));
468
- tools.listFolders = server.tool("listFolders", "List folders in a BrowserStack Test Management project, returning each folder's id and name (plus case counts and sub-folder counts). Pass parent_id to list sub-folders under a specific folder instead of top-level folders." +
469
- NEEDS_PROJECT_ID, ListFoldersSchema.shape, {
463
+ tools.listFolders = server.tool("listFolders", "List folders in a BrowserStack Test Management project, returning each folder's id and name (plus case counts and sub-folder counts). Pass parent_id to list sub-folders under a specific folder instead of top-level folders.", ListFoldersSchema.shape, {
470
464
  title: "List Folders",
471
465
  readOnlyHint: true,
472
466
  openWorldHint: false,
@@ -480,39 +474,35 @@ export default function addTestManagementTools(server, config) {
480
474
  destructiveHint: false,
481
475
  idempotentHint: true,
482
476
  }, (args) => listTemplatesTool(args, config, server));
483
- tools.createTestRun = server.tool("createTestRun", "Create a test run in BrowserStack Test Management." + NEEDS_PROJECT_ID, CreateTestRunSchema.shape, {
477
+ tools.createTestRun = server.tool("createTestRun", "Create a test run in BrowserStack Test Management.", CreateTestRunSchema.shape, {
484
478
  title: "Create Test Run",
485
479
  readOnlyHint: false,
486
480
  openWorldHint: false,
487
481
  destructiveHint: false,
488
482
  idempotentHint: false,
489
483
  }, (args) => createTestRunTool(args, config, server));
490
- tools.listTestRuns = server.tool("listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)" +
491
- NEEDS_PROJECT_ID, ListTestRunsSchema.shape, {
484
+ tools.listTestRuns = server.tool("listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)", ListTestRunsSchema.shape, {
492
485
  title: "List Test Runs",
493
486
  readOnlyHint: true,
494
487
  openWorldHint: false,
495
488
  destructiveHint: false,
496
489
  idempotentHint: true,
497
490
  }, (args) => listTestRunsTool(args, config, server));
498
- tools.updateTestRun = server.tool("updateTestRun", "Update a test run's metadata and/or add test cases to it." +
499
- NEEDS_PROJECT_ID, UpdateTestRunSchema.shape, {
491
+ tools.updateTestRun = server.tool("updateTestRun", "Update a test run's metadata and/or add test cases to it.", UpdateTestRunSchema.shape, {
500
492
  title: "Update Test Run",
501
493
  readOnlyHint: false,
502
494
  openWorldHint: false,
503
495
  destructiveHint: true,
504
496
  idempotentHint: true,
505
497
  }, (args) => updateTestRunTool(args, config, server));
506
- tools.addTestResult = server.tool("addTestResult", "Add a test result to a specific test run via BrowserStack Test Management API." +
507
- NEEDS_PROJECT_ID, AddTestResultSchema.shape, {
498
+ tools.addTestResult = server.tool("addTestResult", "Add a test result to a specific test run via BrowserStack Test Management API.", AddTestResultSchema.shape, {
508
499
  title: "Add Test Result",
509
500
  readOnlyHint: false,
510
501
  openWorldHint: false,
511
502
  destructiveHint: false,
512
503
  idempotentHint: false,
513
504
  }, (args) => addTestResultTool(args, config, server));
514
- tools.uploadProductRequirementFile = server.tool("uploadProductRequirementFile", "Upload files (e.g., PDRs, PDFs) to BrowserStack Test Management and retrieve a file mapping ID. This is utilized for generating test cases from files and is part of the Test Case Generator AI Agent in BrowserStack." +
515
- NEEDS_PROJECT_ID, UploadFileSchema.shape, {
505
+ tools.uploadProductRequirementFile = server.tool("uploadProductRequirementFile", "Upload files (e.g., PDRs, PDFs) to BrowserStack Test Management and retrieve a file mapping ID. This is utilized for generating test cases from files and is part of the Test Case Generator AI Agent in BrowserStack.", UploadFileSchema.shape, {
516
506
  title: "Upload Product Requirement File",
517
507
  readOnlyHint: false,
518
508
  openWorldHint: false,
@@ -526,47 +516,35 @@ export default function addTestManagementTools(server, config) {
526
516
  destructiveHint: false,
527
517
  idempotentHint: false,
528
518
  }, (args, context) => createTestCasesFromFileTool(args, context, config, server));
529
- tools.createLCASteps = server.tool("createLCASteps", "Generate Low Code Automation (LCA) steps for a test case in BrowserStack Test Management using the Low Code Automation Agent." +
530
- NEEDS_PROJECT_ID, CreateLCAStepsSchema.shape, {
519
+ tools.createLCASteps = server.tool("createLCASteps", "Generate Low Code Automation (LCA) steps for a test case in BrowserStack Test Management using the Low Code Automation Agent.", CreateLCAStepsSchema.shape, {
531
520
  title: "Create LCA Steps",
532
521
  readOnlyHint: false,
533
522
  openWorldHint: false,
534
523
  destructiveHint: false,
535
524
  idempotentHint: false,
536
525
  }, (args, context) => createLCAStepsTool(args, context, config, server));
537
- tools.listTestPlans = server.tool("listTestPlans", "List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination." +
538
- NEEDS_PROJECT_ID +
539
- PLAN_WRITES_VIA_AGENT, ListTestPlansSchema.shape, {
526
+ tools.listTestPlans = server.tool("listTestPlans", "List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination.", ListTestPlansSchema.shape, {
540
527
  title: "List Test Plans",
541
528
  readOnlyHint: true,
542
529
  openWorldHint: false,
543
530
  destructiveHint: false,
544
531
  idempotentHint: true,
545
532
  }, (args) => listTestPlansTool(args, config, server));
546
- tools.getTestPlan = server.tool("getTestPlan", "Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports." +
547
- NEEDS_PROJECT_ID +
548
- NEEDS_TEST_PLAN_ID +
549
- PLAN_WRITES_VIA_AGENT, GetTestPlanSchema.shape, {
533
+ tools.getTestPlan = server.tool("getTestPlan", "Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports.", GetTestPlanSchema.shape, {
550
534
  title: "Get Test Plan",
551
535
  readOnlyHint: true,
552
536
  openWorldHint: false,
553
537
  destructiveHint: false,
554
538
  idempotentHint: true,
555
539
  }, (args) => getTestPlanTool(args, config, server));
556
- tools.listSubTestPlans = server.tool("listSubTestPlans", "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination." +
557
- NEEDS_PROJECT_ID +
558
- NEEDS_TEST_PLAN_ID +
559
- PLAN_WRITES_VIA_AGENT, ListSubTestPlansSchema.shape, {
540
+ tools.listSubTestPlans = server.tool("listSubTestPlans", "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination.", ListSubTestPlansSchema.shape, {
560
541
  title: "List Sub Test Plans",
561
542
  readOnlyHint: true,
562
543
  openWorldHint: false,
563
544
  destructiveHint: false,
564
545
  idempotentHint: true,
565
546
  }, (args) => listSubTestPlansTool(args, config, server));
566
- tools.getSubTestPlan = server.tool("getSubTestPlan", "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs." +
567
- NEEDS_PROJECT_ID +
568
- NEEDS_TEST_PLAN_ID +
569
- PLAN_WRITES_VIA_AGENT, GetSubTestPlanSchema.shape, {
547
+ tools.getSubTestPlan = server.tool("getSubTestPlan", "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs.", GetSubTestPlanSchema.shape, {
570
548
  title: "Get Sub Test Plan",
571
549
  readOnlyHint: true,
572
550
  openWorldHint: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.4.0-beta.3",
3
+ "version": "1.5.0-beta.2",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",
@@ -21,7 +21,8 @@
21
21
  "browserstack-mcp-server": "dist/index.js"
22
22
  },
23
23
  "files": [
24
- "dist"
24
+ "dist",
25
+ "capability"
25
26
  ],
26
27
  "keywords": [
27
28
  "mcp",
@@ -1,120 +0,0 @@
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
- /**
105
- * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
106
- *
107
- * `raise_error: false` keeps the status-first contract this transport has always had: the
108
- * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
109
- * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
110
- */
111
- export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
112
- /** The exact form body of the `client_credentials` grant. */
113
- export declare function mintForm(credentials: Credentials): Record<string, string>;
114
- /**
115
- * Return a valid token, minting one only when the cache has nothing fresh.
116
- *
117
- * Minting per tool call would add a round trip to every request and make the token endpoint
118
- * a hot dependency of the whole surface.
119
- */
120
- export declare function mintCentralToken(url: string, credentials: Credentials, transport: TokenTransport, now?: number): Promise<string>;
@@ -1,277 +0,0 @@
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 { apiClient } from "../../lib/apiClient.js";
18
- import appConfig from "../../config.js";
19
- import logger from "../../logger.js";
20
- import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
21
- /**
22
- * BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
23
- *
24
- * `oauth_user_profile` stays because it is what makes the pair obtainable through the
25
- * username+access_key flow at all. `ai_agent_notify` is what Atlas matches on
26
- * (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in
27
- * `web/oauth.py`); both halves move together with Atlas.
28
- *
29
- * THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
30
- * discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
31
- *
32
- * - `ai_agent_notify` is documented there as CLIENT_ID/SECRET auth, and
33
- * `USERNAME_ACCESS_KEY_ONLY_SCOPES` remains only `user_management, oauth_user_profile`.
34
- * We are on the username+access_key flow, which those restrictions are not written for.
35
- * - It is additionally covered by a new
36
- * `APP_REGISTERED_SCOPE_REQUIRED = %w[ai_agent ai_agent_notify]` gate, requiring the
37
- * calling APPLICATION to be registered for it — though that gate sits in the
38
- * `client_id + client_secret` path, not ours.
39
- * - railsApp defines it as the PRODUCT -> AGENT direction: "a product reporting progress
40
- * back to an AI agent for work the agent dispatched." We use it in the opposite
41
- * direction, as an agent -> Atlas inbound credential.
42
- * - `central_ai_s2s`, which this replaces, was deliberately EXCLUDED from that new gate.
43
- *
44
- * So this is strictly more restricted than what it replaces. If the endpoint refuses it, that
45
- * is a PROVISIONING problem — the scope is not available to this credential type or this
46
- * application — and it is reported as one, naming the scope. It is never retried with a
47
- * different scope: a silent downgrade to a different authorization is exactly the kind of
48
- * thing nobody notices until it matters.
49
- */
50
- export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
51
- /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
52
- export const REQUESTED_EXPIRES_IN = 3600;
53
- /**
54
- * Treat a token as stale this long before it actually expires.
55
- *
56
- * NOT the usual small skew. This token is not merely used to open the request — Atlas holds
57
- * it for the life of the run and re-uses it for product egress, so it has to outlive the
58
- * whole call, and our own `/agent` budget is already 330s. Handing out a token with 61
59
- * seconds left would mean a human approves a write and the egress that follows fails on an
60
- * expired credential, which is the exact mid-flight expiry this cache exists to prevent.
61
- */
62
- export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000;
63
- /** The token endpoint gets its own, much shorter budget than `/agent`. */
64
- export const TOKEN_TIMEOUT_MS = 15_000;
65
- /**
66
- * The OAuth2 error codes we are willing to read out of a failure body.
67
- *
68
- * `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description`
69
- * is free text and demonstrably CAN ("access_key <key> is invalid"), which is why only the
70
- * code is ever looked at and only when it is one of these. Anything else is ignored entirely
71
- * and the classification falls back to the status.
72
- */
73
- const SCOPE_ERROR_CODES = [
74
- "invalid_scope",
75
- "unauthorized_client",
76
- "invalid_request",
77
- ];
78
- const CREDENTIAL_ERROR_CODES = [
79
- "invalid_client",
80
- "invalid_grant",
81
- "access_denied",
82
- ];
83
- /**
84
- * Was this refusal about the SCOPE or about the CREDENTIAL?
85
- *
86
- * The two need completely different fixes — provisioning versus a password — so collapsing
87
- * them into one message sends someone to the wrong place entirely. Our form has five fields
88
- * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
89
- * only really be about the scope.
90
- *
91
- * Nothing from the body is ever surfaced; the code is used to classify and then discarded.
92
- */
93
- export function refusalIsAboutScope(status, body) {
94
- const payload = typeof body === "object" && body !== null
95
- ? body
96
- : {};
97
- const code = typeof payload.error === "string" ? payload.error : "";
98
- if (SCOPE_ERROR_CODES.includes(code))
99
- return true;
100
- if (CREDENTIAL_ERROR_CODES.includes(code))
101
- return false;
102
- // No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
103
- // the status is the next best evidence.
104
- return status === 400;
105
- }
106
- /**
107
- * The ways authentication can fail, kept apart because a user cannot act on them otherwise.
108
- *
109
- * `scope refused` is a provisioning problem; `rejected` is "your credentials are wrong";
110
- * `unreachable` is "auth is down". A fourth — Atlas refusing a token we minted successfully —
111
- * is a server misconfiguration and lives in `relay.ts`, because it is discovered from
112
- * `/agent`. Four different fixes, so four different sentences.
113
- */
114
- export const AUTH_SCOPE_REFUSED_DETAIL = (status) => `BrowserStack auth would not issue a token for the scope "${CENTRAL_SCOPE}" (HTTP ${status}). ` +
115
- `YOUR CREDENTIALS ARE NOT THE PROBLEM — this is a provisioning problem: \`ai_agent_notify\` ` +
116
- `is documented as a client_id/secret scope, it is not in the username+access_key allow ` +
117
- `list, and it carries an application-registration requirement. It has to be enabled for ` +
118
- `this account or application; a different password will not help, and this server will ` +
119
- `NOT quietly retry with a weaker scope. NOTHING REACHED THE AGENT — no request was made, ` +
120
- `no prompt appeared and nothing was changed.`;
121
- export const AUTH_REJECTED_DETAIL = (status) => `Your BrowserStack credentials were rejected by BrowserStack auth (HTTP ${status}). ` +
122
- `NOTHING REACHED THE AGENT — no request was made, no prompt appeared and nothing was ` +
123
- `changed. Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.`;
124
- export const AUTH_UNREACHABLE_DETAIL = "Could not reach BrowserStack auth to sign in. NOTHING REACHED THE AGENT — no request " +
125
- "was made, no prompt appeared and nothing was changed. This is a connectivity or " +
126
- "auth-server problem, not a problem with your credentials.";
127
- /**
128
- * A 5xx from auth: their service is down, not your password.
129
- *
130
- * Split out because routing 5xx to `AUTH_REJECTED_DETAIL` actively misdirects the reader,
131
- * and did: a preprod outage returned 503 and the tool answered "Your BrowserStack
132
- * credentials were rejected … Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY",
133
- * sending someone to audit env vars that had worked minutes earlier. The status alone
134
- * settles it — OAuth2 says a bad client is 401/403 and a bad request is 400, so nothing in
135
- * the 5xx range is ever a statement about the caller.
136
- */
137
- export const AUTH_SERVER_ERROR_DETAIL = (status) => `BrowserStack auth is unavailable (HTTP ${status}). YOUR CREDENTIALS ARE NOT THE ` +
138
- `PROBLEM — a 5xx is the auth service failing, not a rejection, so there is nothing to ` +
139
- `change on your side and nothing to retry differently. NOTHING REACHED THE AGENT — no ` +
140
- `request was made, no prompt appeared and nothing was changed. Try again once ` +
141
- `BrowserStack auth is back.`;
142
- export const AUTH_UNUSABLE_DETAIL = (status) => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` +
143
- `AGENT — no request was made, no prompt appeared and nothing was changed.`;
144
- const cache = new Map();
145
- /** Drop every cached token. For tests, and for a credential rotation. */
146
- export function resetTokenCache() {
147
- cache.clear();
148
- }
149
- /**
150
- * The cache key.
151
- *
152
- * Keyed on the access key so that ROTATING it mints immediately rather than leaving a
153
- * revoked credential working until expiry — but on a SHA-256 of it, never the value, so the
154
- * secret is not left sitting in a map key for the life of the process.
155
- */
156
- function cacheKey(url, credentials) {
157
- const digest = createHash("sha256")
158
- .update(credentials.accessKey)
159
- .digest("hex");
160
- return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
161
- }
162
- /**
163
- * The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
164
- *
165
- * `raise_error: false` keeps the status-first contract this transport has always had: the
166
- * caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
167
- * so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
168
- */
169
- export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
170
- return async (url, form) => {
171
- try {
172
- const response = await apiClient.post({
173
- url,
174
- headers: {
175
- "Content-Type": "application/x-www-form-urlencoded",
176
- Accept: "application/json",
177
- },
178
- body: new URLSearchParams(form).toString(),
179
- timeout: timeoutMs,
180
- raise_error: false,
181
- });
182
- return { status: response.status, body: response.data ?? null };
183
- }
184
- catch {
185
- // DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
186
- // carried: it can name the URL and, on some stacks, echo the request body.
187
- return { status: 0, body: null, error: "auth could not be reached" };
188
- }
189
- };
190
- }
191
- /** The exact form body of the `client_credentials` grant. */
192
- export function mintForm(credentials) {
193
- return {
194
- grant_type: "client_credentials",
195
- username: credentials.username,
196
- access_key: credentials.accessKey,
197
- scope: CENTRAL_SCOPE,
198
- expires_in: String(REQUESTED_EXPIRES_IN),
199
- };
200
- }
201
- async function mintOnce(url, credentials, transport) {
202
- const response = await transport(url, mintForm(credentials));
203
- if (response.status === 0)
204
- throw new AskError(AUTH_UNREACHABLE_DETAIL);
205
- // 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
206
- // is worse than saying nothing — it names the caller's credentials as the fault.
207
- if (response.status >= 500) {
208
- throw new AskError(AUTH_SERVER_ERROR_DETAIL(response.status));
209
- }
210
- if (response.status !== 200) {
211
- // ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
212
- // credential one, and nothing out of it is ever put in the message — a non-200 body can
213
- // echo the access key straight back.
214
- throw new AskError(refusalIsAboutScope(response.status, response.body)
215
- ? AUTH_SCOPE_REFUSED_DETAIL(response.status)
216
- : AUTH_REJECTED_DETAIL(response.status));
217
- }
218
- const body = typeof response.body === "object" && response.body !== null
219
- ? response.body
220
- : {};
221
- const token = body.access_token;
222
- if (typeof token !== "string" || !token) {
223
- throw new AskError(AUTH_UNUSABLE_DETAIL(response.status));
224
- }
225
- // Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
226
- // caching for the requested hour when it granted less would hand out a dead token.
227
- const granted = Number(body.expires_in);
228
- const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
229
- return { token, lifetimeMs: seconds * 1000 };
230
- }
231
- /**
232
- * Return a valid token, minting one only when the cache has nothing fresh.
233
- *
234
- * Minting per tool call would add a round trip to every request and make the token endpoint
235
- * a hot dependency of the whole surface.
236
- */
237
- export async function mintCentralToken(url, credentials, transport, now = Date.now()) {
238
- // Refused before any network call, and by name: these ARE the auth credential now, not
239
- // merely attribution, so an empty one is our missing configuration rather than the user's
240
- // rejected password, and must not read like one.
241
- if (!credentials?.username || !credentials?.accessKey) {
242
- throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
243
- "BROWSERSTACK_ACCESS_KEY are required to sign in");
244
- }
245
- // NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
246
- // process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
247
- // data in module-level state there, so remote mode mints per call. Keying on
248
- // username + sha256(accessKey) already means one user can never be SERVED another's token,
249
- // but containment is not the contract; not holding it at all is.
250
- if (appConfig.REMOTE_MCP) {
251
- return mintOnce(url, credentials, transport).then(({ token }) => {
252
- logger.info("askBrowserStackAI: signed in as %s", credentials.username);
253
- return token;
254
- });
255
- }
256
- const key = cacheKey(url, credentials);
257
- const entry = cache.get(key);
258
- if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
259
- return entry.token;
260
- }
261
- // Double-checked through a shared promise: concurrent callers await the same mint.
262
- if (entry?.inflight)
263
- return entry.inflight;
264
- const pending = mintOnce(url, credentials, transport)
265
- .then(({ token, lifetimeMs }) => {
266
- cache.set(key, { token, expiresAt: now + lifetimeMs });
267
- logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
268
- return token;
269
- })
270
- .catch((error) => {
271
- // Never leave a rejected promise cached, or every later call inherits this failure.
272
- cache.delete(key);
273
- throw error;
274
- });
275
- cache.set(key, { token: "", expiresAt: 0, inflight: pending });
276
- return pending;
277
- }