@browserstack/mcp-server 2.3.0-beta.2 → 2.3.0-beta.4

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.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "0.1",
4
- "build_id": "automate-authored",
5
- "automate": {
6
- "summary": "BrowserStack Automate: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, read the page HTML and element text, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, move between tabs and iframes, and read cookies. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are App Automate, which drives an installed app rather than a browser. Scrolling is done through execute_script, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.",
3
+ "version": "1.0",
4
+ "build_id": "8445466_2026-10-19T06:53:46Z",
5
+ "devicecontrol": {
6
+ "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, read the page HTML, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, and move between tabs and iframes. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are mobile device control, which drives an installed app rather than a browser. Scrolling is done through execute_script, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.",
7
7
  "routing_terms": [
8
8
  "automate",
9
9
  "browser",
@@ -31,7 +31,9 @@
31
31
  "network logs",
32
32
  "har",
33
33
  "browser session",
34
- "cross browser"
34
+ "cross browser",
35
+ "device control",
36
+ "browser control"
35
37
  ],
36
38
  "base_url": "https://hub.browserstack.com",
37
39
  "hosts": {
@@ -50,10 +52,12 @@
50
52
  "entity": "browser",
51
53
  "intent": "List every operating system, browser and version combination this account can run a web session on, with the exact names create_session expects.",
52
54
  "guidance": [
53
- "This is a very large answer \u2014 several thousand rows, one per combination \u2014 and there is no filtering on the wire. Narrow it yourself, and never read it whole into a reply.",
55
+ "The endpoint accepts no filtering on the wire \u2014 every query parameter is ignored, and a filtered call comes back byte-identical to the bare one \u2014 so pass `select` instead and never fetch this whole. The full answer is several thousand rows; `select` with `distinct` turns the usual question into a few hundred bytes.",
56
+ "To ask what values EXIST, use distinct rather than reading rows: distinct ['browser'] names every browser, distinct ['os','os_version'] every platform, distinct ['device'] every mobile device. To check one combination, put it in `where` and see whether anything comes back.",
54
57
  "A desktop row carries os, os_version, browser and browser_version, with device null. A mobile row carries device and real_mobile true instead, and its browser_version is null because the browser ships with the OS.",
55
- "The browser names are lower case and are what create_session takes: chrome, firefox, edge, safari, opera, and chromium on mobile.",
56
- "Pass the strings exactly as they appear; an unlisted combination is refused rather than substituted."
58
+ "os is spelled Windows, OS X, android and ios \u2014 two capitalised and two not, in the same column \u2014 and `where` matches case-insensitively, so either spelling finds them. Browser names are lower case apart from chromeForTesting.",
59
+ "Rows come back in the product's own order, which is OLDEST VERSION FIRST, and `limit` takes the first N of them \u2014 so a limit is the wrong way to find the newest build and will hand back Chrome 37 for Windows 11. Ask for the versions with distinct and no limit (a hundred-odd strings, under 3 KB) and read the end of the list.",
60
+ "Pass the strings into create_session exactly as they appear here; an unlisted combination is refused rather than substituted."
57
61
  ],
58
62
  "returns": [
59
63
  "browser",
@@ -74,7 +78,18 @@
74
78
  "$response": "Unauthorized"
75
79
  }
76
80
  },
77
- "host": "api"
81
+ "host": "api",
82
+ "narrowable": {
83
+ "fields": [
84
+ "os",
85
+ "os_version",
86
+ "browser",
87
+ "browser_version",
88
+ "device",
89
+ "real_mobile"
90
+ ],
91
+ "note": "The endpoint takes no parameters of its own, so this narrowing is applied to the response after the call."
92
+ }
78
93
  },
79
94
  {
80
95
  "name": "get_plan",
@@ -204,7 +219,7 @@
204
219
  "type": "string",
205
220
  "maxLength": 255,
206
221
  "description": "Short explanation shown beside the status.",
207
- "example": "Cart totalled correctly after adding two items"
222
+ "example": "The page behaved as expected"
208
223
  }
209
224
  ],
210
225
  "returns": [
@@ -364,14 +379,14 @@
364
379
  "type": "string",
365
380
  "json_path": "/capabilities/alwaysMatch/bstack:options/projectName",
366
381
  "description": "Groups sessions on the dashboard.",
367
- "example": "Checkout flows"
382
+ "example": "Web checkout"
368
383
  },
369
384
  {
370
385
  "name": "build_name",
371
386
  "type": "string",
372
387
  "json_path": "/capabilities/alwaysMatch/bstack:options/buildName",
373
388
  "description": "Build the session is filed under.",
374
- "example": "agent-exploration"
389
+ "example": "nightly"
375
390
  },
376
391
  {
377
392
  "name": "session_name",
@@ -379,7 +394,7 @@
379
394
  "maxLength": 255,
380
395
  "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName",
381
396
  "description": "Name shown for this session.",
382
- "example": "cart totals"
397
+ "example": "sign in and add to basket"
383
398
  }
384
399
  ],
385
400
  "returns": [
@@ -425,7 +440,7 @@
425
440
  "required": true,
426
441
  "format": "uri",
427
442
  "description": "Absolute URL including the scheme.",
428
- "example": "https://www.bstackdemo.com/"
443
+ "example": "https://www.example.com/products"
429
444
  }
430
445
  ],
431
446
  "returns": [
@@ -624,7 +639,7 @@
624
639
  "entity": "browser_session",
625
640
  "intent": "Get the current page's HTML \u2014 use this to find selectors, or to read content a screenshot cannot show.",
626
641
  "guidance": [
627
- "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title and get_element_text answer most questions far more cheaply.",
642
+ "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title answers most questions far more cheaply.",
628
643
  "This is the page as the browser has it now, including anything scripts have changed, not the HTML the server sent.",
629
644
  "Inside a frame this returns that frame's document; switch_to_frame with a null id first to get the top-level page back."
630
645
  ],
@@ -691,7 +706,7 @@
691
706
  "type": "string",
692
707
  "required": true,
693
708
  "description": "The selector itself, interpreted according to using.",
694
- "example": ".shelf-item__buy-btn"
709
+ "example": "#add-to-cart"
695
710
  }
696
711
  ],
697
712
  "returns": [
@@ -748,7 +763,7 @@
748
763
  "type": "string",
749
764
  "required": true,
750
765
  "description": "The selector itself, interpreted according to using.",
751
- "example": ".shelf-item__buy-btn"
766
+ "example": ".product-card"
752
767
  }
753
768
  ],
754
769
  "returns": [
@@ -791,7 +806,7 @@
791
806
  "type": "string",
792
807
  "required": true,
793
808
  "description": "Element id from find_element.",
794
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
809
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
795
810
  }
796
811
  ],
797
812
  "returns": [
@@ -819,7 +834,7 @@
819
834
  "intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.",
820
835
  "guidance": [
821
836
  "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.",
822
- "Read the result back with get_element_property on \"value\", not get_element_text, which is empty for an input.",
837
+ "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text.",
823
838
  "A \\n at the end submits the form on a desktop browser and on Chrome for Android, and is quicker than finding the submit button. IT DOES NOT SUBMIT ON SAFARI FOR iOS \u2014 the text lands in the field and the page stays where it is, whether the newline is sent with the text or on its own. There, click the form's submit button, or call arguments[0].form.submit() through execute_script with the field as the argument.",
824
839
  "The element must be editable; typing into anything else answers \"element not interactable\".",
825
840
  "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead."
@@ -836,7 +851,7 @@
836
851
  "type": "string",
837
852
  "required": true,
838
853
  "description": "Element id from find_element.",
839
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
854
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
840
855
  }
841
856
  ],
842
857
  "body": [
@@ -845,7 +860,7 @@
845
860
  "type": "string",
846
861
  "required": true,
847
862
  "description": "The characters to type. End with \\n to submit.",
848
- "example": "BrowserStack"
863
+ "example": "hello"
849
864
  }
850
865
  ],
851
866
  "returns": [
@@ -888,7 +903,7 @@
888
903
  "type": "string",
889
904
  "required": true,
890
905
  "description": "Element id from find_element.",
891
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
906
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
892
907
  }
893
908
  ],
894
909
  "returns": [
@@ -931,7 +946,7 @@
931
946
  "type": "string",
932
947
  "required": true,
933
948
  "description": "Element id from find_element.",
934
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
949
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
935
950
  }
936
951
  ],
937
952
  "returns": [
@@ -958,7 +973,7 @@
958
973
  "entity": "browser_session",
959
974
  "intent": "Read a live DOM property of an element \u2014 use this for what a field currently holds, or whether a box is checked right now.",
960
975
  "guidance": [
961
- "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. get_element_text is empty for an input, and the \"value\" attribute is the markup's initial value rather than what is in the box now.",
976
+ "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. The \"value\" attribute is the markup's initial value rather than what is in the box now, and an input carries no text.",
962
977
  "Useful names: value, checked, selected, disabled, innerText, href.",
963
978
  "A property the element does not have comes back null, not an error."
964
979
  ],
@@ -974,7 +989,7 @@
974
989
  "type": "string",
975
990
  "required": true,
976
991
  "description": "Element id from find_element.",
977
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
992
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
978
993
  },
979
994
  {
980
995
  "name": "name",
@@ -1023,14 +1038,14 @@
1023
1038
  "type": "string",
1024
1039
  "required": true,
1025
1040
  "description": "Element id from find_element.",
1026
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
1041
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
1027
1042
  },
1028
1043
  {
1029
1044
  "name": "name",
1030
1045
  "type": "string",
1031
1046
  "required": true,
1032
1047
  "description": "The attribute to read.",
1033
- "example": "class"
1048
+ "example": "href"
1034
1049
  }
1035
1050
  ],
1036
1051
  "returns": [
@@ -1072,7 +1087,7 @@
1072
1087
  "type": "string",
1073
1088
  "required": true,
1074
1089
  "description": "Element id from find_element.",
1075
- "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13"
1090
+ "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
1076
1091
  }
1077
1092
  ],
1078
1093
  "returns": [
@@ -1154,7 +1169,7 @@
1154
1169
  "guidance": [
1155
1170
  "Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.",
1156
1171
  "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with execute_script.",
1157
- "get_element_text and get_title answer most checks far more cheaply than reading an image."
1172
+ "get_title and get_element_property answer most checks far more cheaply than reading an image."
1158
1173
  ],
1159
1174
  "path_params": [
1160
1175
  {
@@ -1379,7 +1394,7 @@
1379
1394
  "type": "string",
1380
1395
  "required": true,
1381
1396
  "description": "Handle from list_window_handles.",
1382
- "example": "2FFCF2904DC789DD82BACEF48E43AE93"
1397
+ "example": "00000000000000000000000000000000"
1383
1398
  }
1384
1399
  ],
1385
1400
  "returns": [
@@ -2064,4 +2079,4 @@
2064
2079
  }
2065
2080
  }
2066
2081
  }
2067
- }
2082
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "0.1",
4
- "build_id": "appautomate-demo",
5
- "appautomate": {
6
- "summary": "BrowserStack App Automate: run a real Android or iOS device and drive the app on it step by step. The account side lists the devices this account can use, including private ones, uploads and lists app builds, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link and video \u2014 after the device is released. The session side starts a device with an installed build and drives it: read the UI tree, find and tap elements, type, swipe, screenshot, press keys, rotate, switch between the native app and a web view, push and pull files, move the simulated GPS, change the network profile, launch, close and uninstall apps, answer biometric prompts and feed images to the camera. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Web browsers are not here, and neither is App Live: it has no API an agent can drive, so agent sessions run on App Automate and consume its minutes. A build must exist before a session starts and can only be uploaded from a public link, never a file on disk \u2014 so media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.",
3
+ "version": "1.0",
4
+ "build_id": "6782f7e_2026-10-16T13:54:22Z",
5
+ "mobiledevicecontrol": {
6
+ "summary": "Mobile device control: run a real Android or iOS device and drive the app on it step by step. The account side lists the devices this account can use, including private ones, uploads and lists app builds, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link and video \u2014 after the device is released. The session side starts a device with an installed build and drives it: read the UI tree, find and tap elements, type, swipe, screenshot, press keys, rotate, switch between the native app and a web view, move between the native app and a web view, and launch or close apps. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Browsers are not here \u2014 those are device control, which drives a page rather than an installed app \u2014 and neither is App Live: it has no API an agent can drive, so agent sessions run on App Automate and consume its minutes. A build must exist before a session starts and can only be uploaded from a public link, never a file on disk \u2014 so media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.",
7
7
  "routing_terms": [
8
8
  "app automate",
9
9
  "appium",
@@ -51,7 +51,10 @@
51
51
  "os build",
52
52
  "upload apk",
53
53
  "upload ipa",
54
- "local build"
54
+ "local build",
55
+ "mobile device control",
56
+ "device control",
57
+ "drive a device"
55
58
  ],
56
59
  "base_url": "https://hub-cloud.browserstack.com",
57
60
  "auth": {
@@ -69,10 +72,10 @@
69
72
  "guidance": [
70
73
  "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.",
71
74
  "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.",
72
- "The session ends itself after idle_timeout seconds with no command (90 by default, 300 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.",
73
- "Every session occupies a device and consumes App Automate minutes until it ends: always finish with execute_script setSessionStatus and then delete_session.",
75
+ "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.",
76
+ "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.",
74
77
  "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the App Automate dashboard and start the interactive session there. That window is two-way: a person's taps and scrolls reach the same device, so ask them to watch only, or re-read the page source before every action.",
75
- "device_name and platform_version must be a combination App Automate offers; an unknown pair fails with a session-not-created error naming the capability.",
78
+ "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.",
76
79
  "Set appium_version to 2.19.0 for any session that will use execute_script with a `mobile:` command; the default server is 1.x and answers those with a 404 listing the commands it does know.",
77
80
  "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.",
78
81
  "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.",
@@ -145,21 +148,21 @@
145
148
  "type": "string",
146
149
  "json_path": "/capabilities/alwaysMatch/bstack:options/projectName",
147
150
  "description": "Groups sessions on the App Automate dashboard.",
148
- "example": "Checkout flows"
151
+ "example": "Mobile checkout"
149
152
  },
150
153
  {
151
154
  "name": "build_name",
152
155
  "type": "string",
153
156
  "json_path": "/capabilities/alwaysMatch/bstack:options/buildName",
154
157
  "description": "Build the session is filed under on the dashboard.",
155
- "example": "agent-exploration"
158
+ "example": "nightly"
156
159
  },
157
160
  {
158
161
  "name": "session_name",
159
162
  "type": "string",
160
163
  "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName",
161
164
  "description": "Name shown for this session on the dashboard.",
162
- "example": "Add a product to the cart"
165
+ "example": "sign in and add to basket"
163
166
  },
164
167
  {
165
168
  "name": "interactive_debugging",
@@ -387,7 +390,7 @@
387
390
  "type": "string",
388
391
  "required": true,
389
392
  "description": "The selector itself, interpreted according to using.",
390
- "example": "nav-cart"
393
+ "example": "cart-button"
391
394
  }
392
395
  ],
393
396
  "returns": [
@@ -483,7 +486,7 @@
483
486
  "type": "string",
484
487
  "required": true,
485
488
  "description": "The characters to type.",
486
- "example": "Galaxy S20"
489
+ "example": "hello"
487
490
  }
488
491
  ],
489
492
  "returns": [
@@ -612,8 +615,8 @@
612
615
  "entity": "app_session",
613
616
  "intent": "Run an Appium mobile command or a BrowserStack session command \u2014 scroll a list, press a hardware key, launch or close an app, or mark the session passed or failed.",
614
617
  "guidance": [
615
- "Mark the result before ending the session: script 'browserstack_executor: {\"action\":\"setSessionStatus\",\"arguments\":{\"status\":\"passed\",\"reason\":\"Cart shows one item\"}}' with args []. status is passed or failed; a later call overwrites an earlier one, and the status survives delete_session.",
616
- "Keep the setSessionStatus reason to one plain line under 256 characters with no escape sequences such as newlines.",
618
+ "set_session_status is the capability for marking a session passed or failed, and works after the session has ended too. Only reach for the browserstack_executor setSessionStatus action here when something has to happen from inside the running session.",
619
+ "Keep any executor reason to one plain line under 256 characters with no escape sequences such as newlines.",
617
620
  "Android scroll: script \"mobile: scrollGesture\" with args [{\"left\":100,\"top\":500,\"width\":880,\"height\":1500,\"direction\":\"down\",\"percent\":0.8}] \u2014 value true means more content remains below.",
618
621
  "Which mobile: commands exist depends on the Appium version the session runs. The default Android version is Appium 1.x, which supports scrollGesture, swipeGesture, flingGesture, dragGesture, longClickGesture, doubleClickGesture and pinch gestures, but NOT mobile: pressKey \u2014 that needs a session started with appium_version 2.x or later.",
619
622
  "An unsupported command fails with 404 \"unknown command\", and the message lists every mobile: command the session does support \u2014 read it rather than retrying variants."
@@ -1121,7 +1124,7 @@
1121
1124
  "type": "string",
1122
1125
  "maxLength": 255,
1123
1126
  "description": "Short explanation shown beside the status.",
1124
- "example": "Checkout completed and the cart showed the right total"
1127
+ "example": "The app behaved as expected"
1125
1128
  }
1126
1129
  ],
1127
1130
  "returns": [
@@ -1747,7 +1750,7 @@
1747
1750
  "intent": "Find out which app is in the foreground right now \u2014 use this to confirm the app under test is showing and not the home screen or another app.",
1748
1751
  "guidance": [
1749
1752
  "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.",
1750
- "This is the quickest way to learn the app under test's own package id, which launch_app, terminate_app and uninstall_app all need.",
1753
+ "This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.",
1751
1754
  "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.",
1752
1755
  "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there."
1753
1756
  ],
@@ -2681,7 +2684,7 @@
2681
2684
  "type": "string",
2682
2685
  "required": true,
2683
2686
  "description": "The selector itself, interpreted according to using.",
2684
- "example": "nav-cart"
2687
+ "example": "cart-button"
2685
2688
  }
2686
2689
  ],
2687
2690
  "returns": [
@@ -3308,4 +3311,4 @@
3308
3311
  "api": "https://api-cloud.browserstack.com"
3309
3312
  }
3310
3313
  }
3311
- }
3314
+ }
@@ -1,4 +1,14 @@
1
1
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * Returns the most specific human-readable text for an error raised by an
4
+ * API call, capped at 500 characters. For an AxiosError with a response body
5
+ * it prefers the body's `message`, `error` or `errors` field, serialising
6
+ * objects, and uses the whole body when none of those fields exist. An empty,
7
+ * whitespace-only or HTML-document body falls back to the error's own message.
8
+ * @param err - The error object to describe
9
+ * @param defaultText - The fallback error message
10
+ */
11
+ export declare function errorText(err: unknown, defaultText: string): string;
2
12
  /**
3
13
  * Formats an AxiosError into a CallToolResult with an appropriate message.
4
14
  * @param err - The error object to format
package/dist/lib/error.js CHANGED
@@ -1,23 +1,75 @@
1
1
  import { AxiosError } from "axios";
2
+ const MAX_ERROR_TEXT = 500;
3
+ const BODY_FIELDS = ["message", "error", "errors"];
4
+ function clip(text) {
5
+ return text.length > MAX_ERROR_TEXT ? text.slice(0, MAX_ERROR_TEXT) : text;
6
+ }
7
+ const HTML_DOCUMENT = /^\s*<(!doctype|html)\b/i;
8
+ function stringify(value) {
9
+ if (typeof value === "string") {
10
+ return HTML_DOCUMENT.test(value) ? "" : value.trim();
11
+ }
12
+ if (value === undefined || value === null)
13
+ return "";
14
+ try {
15
+ const json = JSON.stringify(value);
16
+ return json === "{}" || json === "[]" ? "" : (json ?? "");
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ /**
23
+ * Text taken from an API error body: the first non-empty `message`, `error`
24
+ * or `errors` field when any of them is present, otherwise the whole body.
25
+ * Returns undefined when the body carries no usable text.
26
+ */
27
+ function bodyText(data) {
28
+ if (typeof data !== "object" || data === null) {
29
+ const text = stringify(data);
30
+ return text ? clip(text) : undefined;
31
+ }
32
+ const record = data;
33
+ const named = BODY_FIELDS.filter((key) => key in record);
34
+ if (named.length > 0) {
35
+ for (const key of named) {
36
+ const text = stringify(record[key]);
37
+ if (text)
38
+ return clip(text);
39
+ }
40
+ return undefined;
41
+ }
42
+ const text = stringify(record);
43
+ return text ? clip(text) : undefined;
44
+ }
45
+ /**
46
+ * Returns the most specific human-readable text for an error raised by an
47
+ * API call, capped at 500 characters. For an AxiosError with a response body
48
+ * it prefers the body's `message`, `error` or `errors` field, serialising
49
+ * objects, and uses the whole body when none of those fields exist. An empty,
50
+ * whitespace-only or HTML-document body falls back to the error's own message.
51
+ * @param err - The error object to describe
52
+ * @param defaultText - The fallback error message
53
+ */
54
+ export function errorText(err, defaultText) {
55
+ if (err instanceof AxiosError) {
56
+ const text = bodyText(err.response?.data);
57
+ if (text)
58
+ return text;
59
+ return clip(err.message || defaultText);
60
+ }
61
+ if (err instanceof Error)
62
+ return clip(err.message || defaultText);
63
+ return defaultText;
64
+ }
2
65
  /**
3
66
  * Formats an AxiosError into a CallToolResult with an appropriate message.
4
67
  * @param err - The error object to format
5
68
  * @param defaultText - The fallback error message
6
69
  */
7
70
  export function formatAxiosError(err, defaultText) {
8
- let text = defaultText;
9
- if (err instanceof AxiosError && err.response?.data) {
10
- const message = err.response.data.message ||
11
- err.response.data.error ||
12
- err.message ||
13
- defaultText;
14
- text = message;
15
- }
16
- else if (err instanceof Error) {
17
- text = err.message;
18
- }
19
71
  return {
20
- content: [{ type: "text", text }],
72
+ content: [{ type: "text", text: errorText(err, defaultText) }],
21
73
  isError: true,
22
74
  };
23
75
  }
package/dist/lib/utils.js CHANGED
@@ -7,6 +7,9 @@ export function sanitizeUrlParam(param) {
7
7
  return param.replace(/[;&|`$(){}[\]<>]/g, "");
8
8
  }
9
9
  const ONE_MB = 1048576;
10
+ // sharp's default (~268M px) lets one image decode to ~1 GB of RGBA; 25M px caps it near 100 MB.
11
+ const MAX_INPUT_PIXELS = 25_000_000;
12
+ const MAX_IMAGE_DIMENSION = 1920;
10
13
  let sharpConfigured = false;
11
14
  //Compresses a base64 image intelligently to keep it under 1 MB if needed.
12
15
  export async function maybeCompressBase64(base64) {
@@ -25,11 +28,19 @@ export async function maybeCompressBase64(base64) {
25
28
  sharp.concurrency(1);
26
29
  sharpConfigured = true;
27
30
  }
28
- const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer();
31
+ const image = globalConfig.REMOTE_MCP
32
+ ? sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }).resize({
33
+ width: MAX_IMAGE_DIMENSION,
34
+ height: MAX_IMAGE_DIMENSION,
35
+ fit: "inside",
36
+ withoutEnlargement: true,
37
+ })
38
+ : sharp(buffer);
39
+ const compressedBuffer = await image.png({ quality }).toBuffer();
29
40
  return compressedBuffer.toString("base64");
30
41
  }
31
42
  catch (err) {
32
- logger.warn("Image compression unavailable (sharp failed to load); returning the uncompressed image. %s", err instanceof Error ? err.message : String(err));
43
+ logger.warn("Image compression failed; returning the uncompressed image. %s", err instanceof Error ? err.message : String(err));
33
44
  return base64;
34
45
  }
35
46
  }
@@ -1,6 +1,10 @@
1
1
  import { SessionType } from "../../lib/constants.js";
2
2
  import { BrowserStackConfig } from "../../lib/types.js";
3
- export declare function fetchAutomationScreenshots(sessionId: string, sessionType: SessionType | undefined, config: BrowserStackConfig): Promise<{
3
+ export declare const MAX_SCREENSHOT_BYTES: number | undefined;
4
+ export declare function parseScreenshotUrls(text: string): string[];
5
+ export type Screenshot = {
4
6
  url: string;
5
7
  base64: string;
6
- }[]>;
8
+ mimeType: string;
9
+ };
10
+ export declare function fetchAutomationScreenshots(sessionId: string, sessionType: SessionType | undefined, config: BrowserStackConfig): Promise<Screenshot[]>;
@@ -3,6 +3,10 @@ import { SessionType } from "../../lib/constants.js";
3
3
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
4
4
  import { apiClient } from "../../lib/apiClient.js";
5
5
  import globalConfig from "../../config.js";
6
+ import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js";
7
+ export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP
8
+ ? 10 * 1024 * 1024
9
+ : undefined;
6
10
  async function extractScreenshotUrls(sessionId, sessionType, config) {
7
11
  const authString = getBrowserStackAuth(config);
8
12
  const auth = Buffer.from(authString).toString("base64");
@@ -14,47 +18,112 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
14
18
  "Content-Type": "application/json",
15
19
  Authorization: `Basic ${auth}`,
16
20
  },
21
+ maxContentLength: MAX_LOG_BYTES,
17
22
  raise_error: false,
18
23
  });
24
+ if (response.status === 413) {
25
+ throw new Error("The logs for this session are too large (over 5 MB) to find screenshots here. Open the session in the BrowserStack dashboard to view them.");
26
+ }
19
27
  await assertOkResponse(response, "Session");
20
28
  const text = typeof response.data === "string"
21
29
  ? response.data
22
30
  : JSON.stringify(response.data);
31
+ return parseScreenshotUrls(text);
32
+ }
33
+ const LOG_PREFIX = String.raw `^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3} `;
34
+ const LINE_TYPE_PATTERN = new RegExp(`${LOG_PREFIX}([A-Z_]+) `);
35
+ const SCREENSHOT_REQUEST_PATTERN = new RegExp(String.raw `${LOG_PREFIX}REQUEST \[[^\]]+\] GET /session/[^/\s]+/screenshot(\s|$)`);
36
+ const RESPONSE_VALUE_PATTERN = new RegExp(String.raw `${LOG_PREFIX}RESPONSE \{"value"\s*:\s*"(https://[^"]+)"`);
37
+ const DEBUG_SCREENSHOT_PATTERN = new RegExp(String.raw `${LOG_PREFIX}DEBUG (https://\S+)$`);
38
+ const TRUSTED_HOST_PATTERN = /(^|\.)browserstack\.com$|\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$|\.backblazeb2\.com$/;
39
+ function isTrustedScreenshotUrl(value) {
40
+ try {
41
+ const url = new URL(value);
42
+ return url.protocol === "https:" && TRUSTED_HOST_PATTERN.test(url.hostname);
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ export function parseScreenshotUrls(text) {
23
49
  const urls = [];
24
- const SCREENSHOT_PATTERN = /REQUEST.*GET.*\/screenshot/;
25
- const RESPONSE_VALUE_PATTERN = /"value"\s*:\s*"([^"]+)"/;
26
- // Split logs into lines and process them
27
- const lines = text.split("\n");
28
- for (let i = 0; i < lines.length - 1; i++) {
29
- const currentLine = lines[i];
30
- const nextLine = lines[i + 1];
31
- if (SCREENSHOT_PATTERN.test(currentLine)) {
32
- const match = nextLine.match(RESPONSE_VALUE_PATTERN);
33
- if (match && match[1]) {
34
- urls.push(match[1]);
50
+ const lines = text.split("\n").map((line) => line.trimEnd());
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const line = lines[i];
53
+ const debugMatch = line.match(DEBUG_SCREENSHOT_PATTERN);
54
+ if (debugMatch) {
55
+ urls.push(debugMatch[1]);
56
+ continue;
57
+ }
58
+ if (!SCREENSHOT_REQUEST_PATTERN.test(line)) {
59
+ continue;
60
+ }
61
+ for (let j = i + 1; j < lines.length; j++) {
62
+ const lineType = lines[j].match(LINE_TYPE_PATTERN)?.[1];
63
+ if (lineType === "REQUEST") {
64
+ break;
65
+ }
66
+ if (lineType === "RESPONSE") {
67
+ const valueMatch = lines[j].match(RESPONSE_VALUE_PATTERN);
68
+ if (valueMatch) {
69
+ urls.push(valueMatch[1]);
70
+ }
71
+ break;
35
72
  }
36
73
  }
37
74
  }
38
- return urls;
75
+ return [...new Set(urls)].filter(isTrustedScreenshotUrl);
76
+ }
77
+ function detectImageMimeType(buffer) {
78
+ if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
79
+ return "image/jpeg";
80
+ }
81
+ return "image/png";
39
82
  }
83
+ // Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load.
40
84
  async function fetchAndCompress(url) {
41
- const response = await apiClient.get({ url, responseType: "arraybuffer" });
85
+ const response = await apiClient.get({
86
+ url,
87
+ responseType: "arraybuffer",
88
+ maxContentLength: MAX_SCREENSHOT_BYTES,
89
+ raise_error: false,
90
+ });
91
+ if (response.status === 413) {
92
+ return null;
93
+ }
94
+ if (!response.ok) {
95
+ throw new Error(`Failed to download screenshot: ${response.status} ${response.statusText}`);
96
+ }
42
97
  // Axios returns response.data as a Buffer for binary data
43
- const base64 = Buffer.from(response.data).toString("base64");
44
- return { url, base64: await maybeCompressBase64(base64) };
98
+ const buffer = Buffer.from(response.data);
99
+ const base64 = await maybeCompressBase64(buffer.toString("base64"));
100
+ // Detect AFTER compression: sharp re-encodes anything over 1 MB to PNG, so
101
+ // reading the original bytes would label a large JPEG with the wrong type.
102
+ return {
103
+ url,
104
+ base64,
105
+ mimeType: detectImageMimeType(Buffer.from(base64, "base64")),
106
+ };
45
107
  }
46
108
  //Converts screenshot URLs to base64 encoded images
47
109
  async function convertUrlsToBase64(urls) {
110
+ let results;
48
111
  // Hosted pods are memory-constrained: process one image at a time. Local runs
49
112
  // keep the concurrent path.
50
113
  if (globalConfig.REMOTE_MCP) {
51
- const screenshots = [];
114
+ results = [];
52
115
  for (const url of urls) {
53
- screenshots.push(await fetchAndCompress(url));
116
+ results.push(await fetchAndCompress(url));
54
117
  }
55
- return screenshots;
56
118
  }
57
- return Promise.all(urls.map(fetchAndCompress));
119
+ else {
120
+ results = await Promise.all(urls.map(fetchAndCompress));
121
+ }
122
+ const screenshots = results.filter((s) => s !== null);
123
+ if (urls.length > 0 && screenshots.length === 0) {
124
+ throw new Error("The screenshots in this session are too large (over 10 MB each) to fetch here. Open the session in the BrowserStack dashboard to view them.");
125
+ }
126
+ return screenshots;
58
127
  }
59
128
  //Fetches and converts screenshot URLs to base64 encoded images
60
129
  export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
@@ -14,7 +14,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
14
14
  content: [
15
15
  {
16
16
  type: "text",
17
- text: "No screenshots found in the session or some unexpected error occurred",
17
+ text: "No screenshots found in the session logs. Run with browserstack.debug: true to capture visual-log screenshots.",
18
18
  },
19
19
  ],
20
20
  isError: true,
@@ -23,7 +23,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
23
23
  const results = screenshots.map((screenshot, index) => ({
24
24
  type: "image",
25
25
  data: screenshot.base64,
26
- mimeType: "image/png",
26
+ mimeType: screenshot.mimeType,
27
27
  _meta: { url: screenshot.url, index: index + 1 },
28
28
  }));
29
29
  return {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Narrowing a response the product will not narrow itself.
3
+ *
4
+ * invokeCapability makes one request and hands back what came off the wire. That is the
5
+ * right default and it stays the default: a caller reasoning about a product's answer
6
+ * should be reasoning about the product's answer. This is the declared exception.
7
+ *
8
+ * It exists because of `list_browsers`. `/automate/browsers.json` returns every operating
9
+ * system, browser and version combination the account can run — 5,251 rows and 604 KB —
10
+ * and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come
11
+ * back byte-identical to the bare call. Its guidance told the caller to "narrow it
12
+ * yourself", which it had no means to do; the only way to use the capability was to not be
13
+ * an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome
14
+ * runs on Windows 11.
15
+ *
16
+ * WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design:
17
+ *
18
+ * 1. A capability must declare `narrowable` before any of this is reachable. Nothing is
19
+ * applied to a capability that did not ask for it, so the untouched-response promise
20
+ * is intact everywhere it was intact before.
21
+ * 2. Only the fields it names may be used, and a field outside that set is refused BY
22
+ * NAME with the set listed — the same way an unknown host is refused. A caller that
23
+ * guesses `platform` learns what the fields are instead of silently getting everything.
24
+ * 3. The result says it was narrowed, from how many rows, and by what. A smaller answer
25
+ * that does not announce itself is indistinguishable from a product that returned less
26
+ * than it has, which is the failure this must not introduce.
27
+ *
28
+ * It is NOT a query language and should not grow into one. Equality, membership, distinct
29
+ * tuples, projection, a cap. Anything a caller cannot express here is a reason to look for
30
+ * a product endpoint that filters, not a reason to add an operator.
31
+ */
32
+ import { Capability } from "./types.js";
33
+ /** What the caller asked for. Every field is optional; an empty selection narrows nothing. */
34
+ export interface Selection {
35
+ /**
36
+ * Field → the value it must take, or any of several.
37
+ *
38
+ * Strings compare case-insensitively. The product's own spellings are not consistent —
39
+ * `Windows` and `OS X` are capitalised, `android` and `ios` are not, in the same column
40
+ * of the same response — so an exact match would refuse half the corpus on a technicality
41
+ * the caller cannot see from the field name.
42
+ */
43
+ where?: Record<string, unknown>;
44
+ /** Return one row per distinct combination of these fields, and nothing else. */
45
+ distinct?: string[];
46
+ /** Keep only these fields on each row. */
47
+ fields?: string[];
48
+ /** At most this many rows, applied last. */
49
+ limit?: number;
50
+ }
51
+ export interface Narrowing {
52
+ /** Rows the product sent. */
53
+ from: number;
54
+ /** Rows being handed back. */
55
+ to: number;
56
+ /** Echoed so the answer carries the question — a narrowed body is not self-describing. */
57
+ by: Selection;
58
+ }
59
+ /**
60
+ * Apply a selection to a response, or refuse it.
61
+ *
62
+ * Returns `undefined` when there is nothing to do — no selection, or a capability that
63
+ * declares none — so the caller hands back the original body and the default holds.
64
+ */
65
+ export declare function narrowResponse(capability: Capability, body: unknown, selection: Selection | undefined): {
66
+ body: unknown;
67
+ narrowing: Narrowing;
68
+ } | undefined;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Narrowing a response the product will not narrow itself.
3
+ *
4
+ * invokeCapability makes one request and hands back what came off the wire. That is the
5
+ * right default and it stays the default: a caller reasoning about a product's answer
6
+ * should be reasoning about the product's answer. This is the declared exception.
7
+ *
8
+ * It exists because of `list_browsers`. `/automate/browsers.json` returns every operating
9
+ * system, browser and version combination the account can run — 5,251 rows and 604 KB —
10
+ * and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come
11
+ * back byte-identical to the bare call. Its guidance told the caller to "narrow it
12
+ * yourself", which it had no means to do; the only way to use the capability was to not be
13
+ * an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome
14
+ * runs on Windows 11.
15
+ *
16
+ * WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design:
17
+ *
18
+ * 1. A capability must declare `narrowable` before any of this is reachable. Nothing is
19
+ * applied to a capability that did not ask for it, so the untouched-response promise
20
+ * is intact everywhere it was intact before.
21
+ * 2. Only the fields it names may be used, and a field outside that set is refused BY
22
+ * NAME with the set listed — the same way an unknown host is refused. A caller that
23
+ * guesses `platform` learns what the fields are instead of silently getting everything.
24
+ * 3. The result says it was narrowed, from how many rows, and by what. A smaller answer
25
+ * that does not announce itself is indistinguishable from a product that returned less
26
+ * than it has, which is the failure this must not introduce.
27
+ *
28
+ * It is NOT a query language and should not grow into one. Equality, membership, distinct
29
+ * tuples, projection, a cap. Anything a caller cannot express here is a reason to look for
30
+ * a product endpoint that filters, not a reason to add an operator.
31
+ */
32
+ import { InvocationError } from "./index-loader.js";
33
+ function isSelectionEmpty(selection) {
34
+ return (!selection.where &&
35
+ !selection.distinct &&
36
+ !selection.fields &&
37
+ selection.limit === undefined);
38
+ }
39
+ /** The array inside a body, by the capability's declared path. */
40
+ function rowsAt(body, path) {
41
+ let here = body;
42
+ for (const step of (path ?? "").split(".").filter(Boolean)) {
43
+ if (typeof here !== "object" || here === null)
44
+ return undefined;
45
+ here = here[step];
46
+ }
47
+ return Array.isArray(here) ? here : undefined;
48
+ }
49
+ /** The body with its array replaced, leaving every sibling key as the product sent it. */
50
+ function withRows(body, path, rows) {
51
+ const steps = (path ?? "").split(".").filter(Boolean);
52
+ if (steps.length === 0)
53
+ return rows;
54
+ const clone = {
55
+ ...body,
56
+ };
57
+ let here = clone;
58
+ for (const step of steps.slice(0, -1)) {
59
+ here[step] = { ...here[step] };
60
+ here = here[step];
61
+ }
62
+ here[steps[steps.length - 1]] = rows;
63
+ return clone;
64
+ }
65
+ function sameValue(cell, wanted) {
66
+ if (typeof cell === "string" && typeof wanted === "string")
67
+ return cell.toLowerCase() === wanted.toLowerCase();
68
+ // `real_mobile` is true or null, and a caller who writes "true" means true.
69
+ if (typeof cell === "boolean" && typeof wanted === "string")
70
+ return String(cell) === wanted.toLowerCase();
71
+ if (cell === null || cell === undefined)
72
+ return wanted === null || wanted === "null";
73
+ return cell === wanted;
74
+ }
75
+ function assertKnownFields(used, allowed, what) {
76
+ const unknown = used.filter((field) => !allowed.includes(field));
77
+ if (unknown.length > 0)
78
+ throw new InvocationError(`${what} names ${unknown.map((f) => `'${f}'`).join(", ")}, which this capability ` +
79
+ `cannot be narrowed on. The fields it accepts are: ${allowed.join(", ")}.`);
80
+ }
81
+ /**
82
+ * Apply a selection to a response, or refuse it.
83
+ *
84
+ * Returns `undefined` when there is nothing to do — no selection, or a capability that
85
+ * declares none — so the caller hands back the original body and the default holds.
86
+ */
87
+ export function narrowResponse(capability, body, selection) {
88
+ if (!selection || isSelectionEmpty(selection))
89
+ return undefined;
90
+ const declared = capability.narrowable;
91
+ if (!declared)
92
+ throw new InvocationError("that capability does not support `select`: it returns the product's response as " +
93
+ "sent. describeCapability names the ones that do, under `narrowable`.");
94
+ const allowed = declared.fields;
95
+ assertKnownFields(Object.keys(selection.where ?? {}), allowed, "`where`");
96
+ assertKnownFields(selection.distinct ?? [], allowed, "`distinct`");
97
+ assertKnownFields(selection.fields ?? [], allowed, "`fields`");
98
+ const rows = rowsAt(body, declared.path);
99
+ // A body that is not the shape the capability promised is handed back whole. Narrowing
100
+ // an error payload, or a response that changed shape, would hide exactly the thing the
101
+ // caller needs to see.
102
+ if (!rows)
103
+ return undefined;
104
+ let out = rows;
105
+ for (const [field, wanted] of Object.entries(selection.where ?? {})) {
106
+ const wantedValues = Array.isArray(wanted) ? wanted : [wanted];
107
+ out = out.filter((row) => {
108
+ const cell = row?.[field];
109
+ return wantedValues.some((value) => sameValue(cell, value));
110
+ });
111
+ }
112
+ if (selection.distinct?.length) {
113
+ const seen = new Set();
114
+ const unique = [];
115
+ for (const row of out) {
116
+ const picked = Object.fromEntries(selection.distinct.map((field) => [
117
+ field,
118
+ row?.[field],
119
+ ]));
120
+ const key = JSON.stringify(picked);
121
+ if (seen.has(key))
122
+ continue;
123
+ seen.add(key);
124
+ unique.push(picked);
125
+ }
126
+ out = unique;
127
+ }
128
+ else if (selection.fields?.length) {
129
+ out = out.map((row) => Object.fromEntries(selection.fields.map((field) => [
130
+ field,
131
+ row?.[field],
132
+ ])));
133
+ }
134
+ if (selection.limit !== undefined)
135
+ out = out.slice(0, Math.max(0, selection.limit));
136
+ return {
137
+ body: withRows(body, declared.path, out),
138
+ narrowing: { from: rows.length, to: out.length, by: selection },
139
+ };
140
+ }
@@ -21,6 +21,7 @@ import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
21
21
  import { redact } from "./redact.js";
22
22
  import { fetchTransport } from "./egress.js";
23
23
  import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
24
+ import { narrowResponse } from "./narrow.js";
24
25
  import { invoke } from "./resolve.js";
25
26
  import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
26
27
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
@@ -793,8 +794,27 @@ export function addCapabilityRegistryTools(server, deps, config) {
793
794
  // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
794
795
  // and spreading the capability would leak them through whenever the resolved value
795
796
  // is absent.
796
- const { responses: unresolved, method, path, ...contract } = capability;
797
+ //
798
+ // THE WIRE FIELDS GO WITH IT, for the same reason the route does: they describe how
799
+ // WE reach the product, not anything the caller supplies or acts on. `host` names
800
+ // which of the product's origins serves this one capability; `encoding` and
801
+ // `send_empty_body` say how the request body is framed; `local_only` and `disabled`
802
+ // are claims about which surface publishes it, already answered by the capability
803
+ // being present at all. A contract that shows them invites a caller to reason about
804
+ // plumbing it cannot influence — and `host: "api"` was doing exactly that in a
805
+ // shipped describe response.
806
+ //
807
+ // `invocable` STAYS, and is the one of these that is not plumbing. Presence says the
808
+ // capability is published, which is not the same as callable: a reference-only entry
809
+ // is published precisely so search can still find it and say what to do instead.
810
+ // Stripping the flag would leave that fact only in a prose guidance line.
811
+ const { responses: unresolved, method, path, host, encoding, send_empty_body, local_only, disabled, ...contract } = capability;
797
812
  void unresolved;
813
+ void host;
814
+ void encoding;
815
+ void send_empty_body;
816
+ void local_only;
817
+ void disabled;
798
818
  return ok({
799
819
  // No build_id here either, same reason. `product` stays: describeCapability
800
820
  // resolves by NAME and its product argument is optional, so the answer has to
@@ -826,7 +846,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
826
846
  "as given — that is the handle. Only when a result carries no `name` (some products " +
827
847
  "do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
828
848
  "Arguments go in path_params / query / body under the spec's own names. One call " +
829
- "makes exactly one request and returns the product's own response untouched; when " +
849
+ "makes exactly one request and returns the product's own response as sent — unless " +
850
+ "you pass `select`, which only capabilities whose contract carries `narrowable` " +
851
+ "accept, and which reports what it narrowed. When " +
830
852
  "`completed` is false there is another page, which you fetch by sending the " +
831
853
  "capability's own page parameter. If the mode is 'write' you MUST ask the user " +
832
854
  "first, then resend with user_permission='granted' and a change_summary; both are " +
@@ -870,6 +892,29 @@ export function addCapabilityRegistryTools(server, deps, config) {
870
892
  .string()
871
893
  .optional()
872
894
  .describe("What will change. Required for writes."),
895
+ select: z
896
+ .object({
897
+ where: z
898
+ .record(z.string(), z.any())
899
+ .optional()
900
+ .describe("Field -> the value it must take, or an array of accepted values. Strings " +
901
+ "match case-insensitively."),
902
+ distinct: z
903
+ .array(z.string())
904
+ .optional()
905
+ .describe("Return one row per distinct combination of these fields and nothing else — " +
906
+ "the cheapest way to ask what values exist."),
907
+ fields: z
908
+ .array(z.string())
909
+ .optional()
910
+ .describe("Keep only these fields on each row."),
911
+ limit: z.number().optional().describe("At most this many rows."),
912
+ })
913
+ .optional()
914
+ .describe("ONLY for a capability whose contract carries `narrowable`, which names the " +
915
+ "fields this may use; on any other capability it is refused. Those endpoints " +
916
+ "take no filtering parameters of their own, so this is applied to the response " +
917
+ "here, and the result reports what it narrowed and from how many rows."),
873
918
  }, {
874
919
  title: "Invoke Capability",
875
920
  // Not read-only: this is the one tool that writes. Never destructive, because
@@ -949,6 +994,20 @@ export function addCapabilityRegistryTools(server, deps, config) {
949
994
  }
950
995
  }
951
996
  const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
997
+ // NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller
998
+ // needs whole, and a selection that happened to match none of its keys would
999
+ // quietly empty it. `narrowResponse` refuses an unsupported `select` by throwing,
1000
+ // which lands in the InvocationError branch below with the accepted fields listed.
1001
+ const narrowed = result.ok
1002
+ ? narrowResponse(capability, result.http_response.body, input.select)
1003
+ : undefined;
1004
+ const answer = narrowed
1005
+ ? {
1006
+ ...result,
1007
+ http_response: { ...result.http_response, body: narrowed.body },
1008
+ narrowed: narrowed.narrowing,
1009
+ }
1010
+ : result;
952
1011
  // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
953
1012
  // throwing, so `success` keeps its tool-level meaning and these two fields carry
954
1013
  // whether the product call worked. Status 0 means it could not be reached.
@@ -957,8 +1016,14 @@ export function addCapabilityRegistryTools(server, deps, config) {
957
1016
  upstream_ok: result.ok,
958
1017
  upstream_status: result.http_response.status,
959
1018
  change_summary: redact(input.change_summary),
1019
+ ...(narrowed
1020
+ ? {
1021
+ narrowed_from: narrowed.narrowing.from,
1022
+ narrowed_to: narrowed.narrowing.to,
1023
+ }
1024
+ : {}),
960
1025
  });
961
- return ok(result);
1026
+ return ok(answer);
962
1027
  }
963
1028
  catch (error) {
964
1029
  if (error instanceof InvocationError)
@@ -117,24 +117,6 @@ export interface ProductAmbiguity {
117
117
  /** No word in the request belongs to ANY product: there is nothing to route on. */
118
118
  unknown?: boolean;
119
119
  }
120
- /**
121
- * Products whose OWN vocabulary the query hits, when no word in the query settles it.
122
- *
123
- * Requiring `product` made every CALL unambiguous and did nothing about the agent making
124
- * two of them: "list all projects" was answered by searching tm, then Load Testing, then
125
- * merging — a question about which product the user meant, answered by guessing both.
126
- * Tool-description prose asking the agent to check with the user is a suggestion it is
127
- * free to decline, and declining is cheaper than interrupting someone.
128
- *
129
- * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
130
- * everything look shared: Load Testing's "load test" and tm's "test case" both yield
131
- * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
132
- *
133
- * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
134
- * both products claim, and `test case`, which only tm does — so the query has already
135
- * answered the question and there is nothing to ask. Only queries whose every recognised
136
- * word is shared are genuinely undecided.
137
- */
138
120
  export declare function ambiguousProducts(products: Record<string, ProductIndex>, query: string | undefined): ProductAmbiguity;
139
121
  /** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
140
122
  export interface VocabularyEntry {
@@ -122,11 +122,32 @@ export function isBrowseQuery(query) {
122
122
  const q = (query || "").trim().toLowerCase();
123
123
  return q === "*" || q === "all";
124
124
  }
125
+ /**
126
+ * OUR OWN NAME, WHICH IS NOT ROUTING VOCABULARY.
127
+ *
128
+ * Every product here is BrowserStack's, so the word distinguishes none of them — it is a
129
+ * stopword in the strict sense. It could not be spelled as one, because the camelCase
130
+ * split above runs first and turns "BrowserStack" into `browser` + `stack`, two words that
131
+ * ARE vocabulary: `browser` is a devicecontrol entity and an alias on a tm configuration.
132
+ *
133
+ * So the company's name claimed a product's noun. "Which Android apps have I uploaded to
134
+ * BrowserStack?" clashed over `browser` across four products, and the question put to the
135
+ * user — devicecontrol or mobiledevicecontrol or tm or tra — was unanswerable, because no
136
+ * word in the request was really about browsers at all. It reached further than queries
137
+ * that name us: the tm and tra summaries say "BrowserStack", so those two became claimants
138
+ * of `browser` permanently, and "what browsers can I use" clashed four ways on a word only
139
+ * devicecontrol models.
140
+ *
141
+ * Matched after the split and the fold, so one pattern covers "BrowserStack", "browserstack"
142
+ * and "browser stack". `bstack` is the same name in the shorthand the logs and repos use.
143
+ */
144
+ const BRAND = /\bbrowser\s*stack\b|\bbstack\b/g;
125
145
  export function terms(text) {
126
146
  return [
127
147
  ...(text || "")
128
148
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
129
149
  .toLowerCase()
150
+ .replace(BRAND, " ")
130
151
  .matchAll(WORD),
131
152
  ]
132
153
  .map((match) => match[0])
@@ -530,6 +551,22 @@ const GENERIC_ANCHORS = new Set(["test", "tests", "testing"]);
530
551
  * answered the question and there is nothing to ask. Only queries whose every recognised
531
552
  * word is shared are genuinely undecided.
532
553
  */
554
+ /**
555
+ * The words that do disclaiming work in a product summary.
556
+ *
557
+ * Deliberately a list of markers rather than anything resembling negation parsing: the
558
+ * sentences this has to catch are written to one pattern ("X is not here", "neither is Y",
559
+ * "unlike Test Management", "rather than a browser"), and a looser rule would start
560
+ * discarding prose that claims.
561
+ */
562
+ const DISCLAIMS = /\b(?:not|never|neither|nor|unlike|rather than|instead of|except)\b/i;
563
+ /** A summary with its disclaiming sentences removed, for the claimant pass only. */
564
+ function claimingSentences(summary) {
565
+ return (summary ?? "")
566
+ .split(/(?<=[.!?])\s+/)
567
+ .filter((sentence) => !DISCLAIMS.test(sentence))
568
+ .join(" ");
569
+ }
533
570
  export function ambiguousProducts(products, query) {
534
571
  const none = { products: [], terms: [] };
535
572
  if (terms(query).length === 0)
@@ -580,9 +617,20 @@ export function ambiguousProducts(products, query) {
580
617
  // product already models catches that, and refusing to mine NEW terms out of it is what
581
618
  // keeps the prose from taking over: a summary is a paragraph of verbs and nouns written
582
619
  // to sell the product, and every word in it would otherwise become routing vocabulary.
620
+ //
621
+ // AND ONLY FROM A SENTENCE THAT CLAIMS. These summaries route deliberately — each one
622
+ // carries a sentence saying what is NOT in it and where to go instead, which is the most
623
+ // useful sentence in the paragraph and was being read as the opposite of what it says.
624
+ // "Browsers are not here — those are device control" made mobiledevicecontrol a claimant
625
+ // of `browser`, and "Mobile APPS are not here" made devicecontrol a claimant of `app`.
626
+ // Both products were then offered to the user for a word their own summary disowns.
627
+ //
628
+ // Dropping a sentence can only ever narrow a clash, never misdirect one: prose cannot
629
+ // introduce a term, so the worst case is a clash that goes unreported — and a product
630
+ // that genuinely handles something does not say so in a negated sentence.
583
631
  const summaries = Object.fromEntries(Object.entries(products).map(([product, index]) => [
584
632
  product,
585
- ` ${fold(index.summary ?? "")} `,
633
+ ` ${fold(claimingSentences(index.summary))} `,
586
634
  ]));
587
635
  for (const [key, claimants] of owners) {
588
636
  for (const [product, text] of Object.entries(summaries)) {
@@ -229,6 +229,32 @@ export interface Capability {
229
229
  * something this can verify.
230
230
  */
231
231
  send_empty_body?: boolean;
232
+ /**
233
+ * This capability's array response may be narrowed by the server, on these fields.
234
+ *
235
+ * THE PRODUCT CANNOT DO IT. Declaring this is a statement that the endpoint offers no
236
+ * filtering of its own — not that ours is more convenient. `/automate/browsers.json`
237
+ * returns 5,251 rows and ignores every query parameter it is given, so a caller asking
238
+ * which browsers run on Windows had to receive 604 KB and find out itself.
239
+ *
240
+ * Where the wire DOES filter, declare the parameters and let the product filter: a
241
+ * server-side pass still transfers the whole response and still costs the round trip,
242
+ * and it moves the contract off the product and onto us.
243
+ *
244
+ * Unset — which is every other capability — means `select` is refused outright and the
245
+ * response is handed back exactly as sent.
246
+ */
247
+ narrowable?: {
248
+ /** The only fields `select` may name. Anything else is refused with this list. */
249
+ fields: string[];
250
+ /**
251
+ * Dotted path to the array inside the body; omitted when the body IS the array.
252
+ * Siblings of the array are preserved, so an envelope survives the narrowing.
253
+ */
254
+ path?: string;
255
+ /** One line for the contract: why this is here rather than on the wire. */
256
+ note?: string;
257
+ };
232
258
  paginated?: boolean;
233
259
  /** The largest page the operation declares. */
234
260
  max_page_size?: number;
@@ -1,6 +1,6 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import { z } from "zod";
3
- import { formatAxiosError } from "../../lib/error.js";
3
+ import { errorText } from "../../lib/error.js";
4
4
  import { fetchFormFields, normalizeDefaultFields as normalizeDefaultFieldsFromForm, projectIdentifierToId, } from "./TCG-utils/api.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
6
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
@@ -218,12 +218,14 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
218
218
  isError: true,
219
219
  };
220
220
  }
221
- const errorMessage = formatAxiosError(err, "Failed to update test case");
221
+ const status = err.response?.status;
222
+ const detail = errorText(err, "unknown error");
223
+ const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
222
224
  return {
223
225
  content: [
224
226
  {
225
227
  type: "text",
226
- text: `Failed to update test case: ${errorMessage}. Please verify your credentials and try again.`,
228
+ text: `Failed to update test case${status ? ` (HTTP ${status})` : ""}: ${detail}.${hint}`,
227
229
  },
228
230
  ],
229
231
  isError: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "2.3.0-beta.2",
3
+ "version": "2.3.0-beta.4",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",