@browserstack/mcp-server 1.5.0-beta.18 → 1.5.0-beta.19

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,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "1.32",
3
+ "version": "1.37",
4
4
  "build_id": "cb63a4a5_2026-09-16T18:02:57Z",
5
5
  "tm": {
6
6
  "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. Destructive capabilities exist and always need explicit human approval.",
@@ -951,7 +951,7 @@
951
951
  "name": "status",
952
952
  "type": "integer",
953
953
  "json_path": "/test_case/status",
954
- "description": "Numeric status option id — NOT the display name. Resolve it from the project's system field values first."
954
+ "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values_v1 (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`."
955
955
  },
956
956
  {
957
957
  "name": "tags",
@@ -1076,6 +1076,7 @@
1076
1076
  }
1077
1077
  },
1078
1078
  "guidance": [
1079
+ "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Verified on production 2026-09-23: custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.",
1079
1080
  "ALWAYS send `test_case.custom_fields`, `{}` when you are changing none. Omitting the key dereferences a nil hash and returns 500 — this is the single most common failure on this route.",
1080
1081
  "priority, status, case_type and automation_state must be NUMERIC option ids here. The single-case edit accepts a display name for some of them; this path does not, and a name is written through as a broken id rather than rejected.",
1081
1082
  "`tags` REPLACES the whole tag list on every selected case. Read the existing tags and send the union if you mean to add. The same is true of `issues`, which is sent as `[]` when you omit it.",
@@ -1269,6 +1270,7 @@
1269
1270
  }
1270
1271
  },
1271
1272
  "guidance": [
1273
+ "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Verified on production 2026-09-23: custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.",
1272
1274
  "The selection is `mapping_ids` — the run's per-case mapping ids from the run's test-case listing. These are NOT test case ids and NOT TC-NNN identifiers; passing case ids here silently matches nothing.",
1273
1275
  "The whole body is FLAT — there is no `test_case` wrapper on this route, unlike the project-wide bulk test-case capabilities, which all nest under a `test_case` wrapper.",
1274
1276
  "Two ways to name a status: `status_id` (numeric, authoritative) or `status` (display name, resolved server-side). When the name cannot be resolved the mark proceeds and keeps the existing status, so prefer the id.",
@@ -5358,7 +5360,7 @@
5358
5360
  {
5359
5361
  "name": "description_fields",
5360
5362
  "type": "array",
5361
- "description": "Ordered fields placed in the template's description section. Resolve real field ids first — an invented id is rejected upstream. Omitting this creates a template with an empty description section.",
5363
+ "description": "Ordered fields placed in the template's description section. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.",
5362
5364
  "fields": [
5363
5365
  {
5364
5366
  "name": "id",
@@ -5381,7 +5383,7 @@
5381
5383
  {
5382
5384
  "name": "properties_fields",
5383
5385
  "type": "array",
5384
- "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields — the template gets exactly the ids you send.",
5386
+ "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields — the template gets exactly the ids you send. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.",
5385
5387
  "fields": [
5386
5388
  {
5387
5389
  "name": "id",
@@ -5402,7 +5404,7 @@
5402
5404
  {
5403
5405
  "name": "linked_projects",
5404
5406
  "type": "object",
5405
- "description": "Projects the template is linked to at creation. Omit it and the server defaults every flag to false with no projects, so the template exists but is offered in no project until it is linked.",
5407
+ "description": "Object, and the server's own error echo names the keys: `{link_projects:[<project_id>,...], linked_to_all_projects:<bool>, link_to_future_projects:<bool>}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.",
5406
5408
  "fields": [
5407
5409
  {
5408
5410
  "name": "link_to_future_projects",
@@ -5431,11 +5433,14 @@
5431
5433
  ],
5432
5434
  "intent": "Create a new test-case template — define which description and property fields a test case gets and, optionally, which projects it applies to.",
5433
5435
  "guidance": [
5436
+ "NON-REMOVABLE SYSTEM FIELDS 17 AND 18 MUST BE INCLUDED or the create fails with 400 `templateFieldValidationError: Non-removable System fields not present in template.` These are the Review & Approve pair. You cannot discover them by reading an existing template: get_template_v2 DROPS them from its response (`field_count` 16 vs 14 entries returned, on every template including one just created with all 16), so a read-then-clone round trip always fails. Add {id:17,...} and {id:18,...} to properties_fields explicitly.",
5437
+ "Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.",
5438
+ "Object, and the server's own error echo names the keys: `{link_projects:[<project_id>,...], linked_to_all_projects:<bool>, link_to_future_projects:<bool>}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.",
5434
5439
  "Paid plans only. On a free plan the gate answers with HTTP 200 and a body of success:false / 'This Feature is only for paid customers'. Check the success flag, not the status code, before reporting the template as created.",
5435
- "Field ids cannot be guessed. Resolve the workspace's system fields and custom-field definitions first and mark is_system true for built-in fields.",
5436
5440
  "Nothing is validated locally — name, field ids and project ids all go straight upstream, so a malformed body surfaces as a 400 with a single message rather than a per-field error list.",
5437
5441
  "A create does not auto-inject the Review & Approve system fields, but a later update of properties_fields will. The two calls therefore do not behave the same way on the same field list.",
5438
- "The 201 body carries the full template including its fields — read it rather than re-fetching, and keep the returned id."
5442
+ "The 201 body carries the full template including its fields — read it rather than re-fetching, and keep the returned id.",
5443
+ "Verified working on PRODUCTION 2026-09-23: 201 with the full template echoed, linked to the named project. The earlier 403 'This action is not permitted for the user' was a permission gap on the credential used, NOT the paid-plan gate the older note claimed."
5439
5444
  ],
5440
5445
  "returns": [
5441
5446
  "success",
@@ -8005,7 +8010,7 @@
8005
8010
  "type": "integer",
8006
8011
  "example": 496,
8007
8012
  "json_path": "/test_case/status",
8008
- "description": "Numeric status option id. A display NAME is not resolved on this path — it is written through as the id."
8013
+ "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values_v1 (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`."
8009
8014
  },
8010
8015
  {
8011
8016
  "name": "steps",
@@ -8144,6 +8149,7 @@
8144
8149
  }
8145
8150
  },
8146
8151
  "guidance": [
8152
+ "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Verified on production 2026-09-23: custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.",
8147
8153
  "The response is `{success: true}` and NOTHING ELSE. It does not return the updated case, so re-read the case if you need to confirm or show the new values. (The POST .../edit sibling does return `data.test_case`.)",
8148
8154
  "Only the keys present in `test_case` are applied — this is the partial-update path. The POST .../edit on the same case rebuilds more of the record, including resetting `review_status`.",
8149
8155
  "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.",
@@ -8292,7 +8298,7 @@
8292
8298
  "name": "status",
8293
8299
  "type": "integer",
8294
8300
  "json_path": "/test_case/status",
8295
- "description": "Numeric status option id. A display NAME is not resolved on this path — it is written through as the id."
8301
+ "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values_v1 (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`."
8296
8302
  },
8297
8303
  {
8298
8304
  "name": "tags",
@@ -8399,6 +8405,7 @@
8399
8405
  }
8400
8406
  },
8401
8407
  "guidance": [
8408
+ "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Verified on production 2026-09-23: custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.",
8402
8409
  "This is the FULL edit path, not a patch: `review_status` is recomputed on every call (omitted, it falls back to the project's review-approve default), and `template` defaults to `test_case_text` when you omit it, which discards any `test_case_steps` you sent. Send the case's current template unless you mean to change it.",
8403
8410
  "For a targeted single-field change prefer the PATCH .../edit-v2 sibling, which applies only the keys you send.",
8404
8411
  "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.",
@@ -15340,19 +15347,10 @@
15340
15347
  "success",
15341
15348
  "defects",
15342
15349
  "info",
15343
- "id",
15344
15350
  "issue_id",
15345
15351
  "issue_type",
15346
- "issue_status",
15347
- "priority",
15348
- "record_status",
15349
- "entity_type",
15350
- "entity_id",
15351
- "entity_uuid",
15352
- "project_id",
15353
- "metadata",
15354
- "created_at",
15355
- "updated_at"
15352
+ "jira_id",
15353
+ "created_at"
15356
15354
  ],
15357
15355
  "max_page_size": 30,
15358
15356
  "paginated": true,
@@ -15377,12 +15375,12 @@
15377
15375
  }
15378
15376
  },
15379
15377
  "guidance": [
15380
- "This is the session-wide view: rows carry entity_type and entity_id, so the list can include both links made on the session itself and links made on individual log entries. Read entity_type to tell them apart; read a log's own `defects` if you only want that log's.",
15381
- "Rows are full issue-link records, not just {issue_id, issue_type}. Each also carries id, issue_status, priority, record_status, entity_type, entity_id, project_id, created_at, updated_at and a free-form metadata object (ticket url, title, category) — the fields a caller normally needs to render a defect.",
15382
- "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'}.",
15383
- "`per_page` is accepted and ignored — the page size is fixed at 30. Walk `p` until a page comes back short.",
15384
- "There are no filters on this endpoint: no status, tracker or date narrowing. Filter client-side.",
15385
- "Defect links written by the session or log write calls are applied asynchronously, so a defect linked a moment ago may not be here yet — retry rather than concluding the link failed.",
15378
+ "ONLY LOG-LEVEL DEFECTS APPEAR HERE. A defect linked on a session LOG shows up immediately; an issue linked on the SESSION itself (the `issues` array on create/update_exploratory_session) NEVER appears, though it persists and is readable from the session's own detail. Verified on production 2026-09-23 with both linked to one session: the log defect listed on the first read, the session issue never listed at all.",
15379
+ "The row is FOUR fields - {issue_id, issue_type, jira_id, created_at}. It is NOT the full issue-link record: no id, entity_type, entity_id, entity_uuid, issue_status, priority, record_status, project_id, metadata or updated_at, all of which an earlier version of this contract promised.",
15380
+ "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected as 400 {success:false, error:'Invalid Params'}.",
15381
+ "`per_page` is accepted and ignored - the page size is fixed at 30. Walk `p` until a page comes back short.",
15382
+ "No filters: no status, tracker or date narrowing. Filter client-side.",
15383
+ "There is no project-wide defect listing in this index. 'What defects exist in this project' means listing sessions and issuing one request per session.",
15386
15384
  "Everything is at the top level: defects, info. There is no `data` envelope.",
15387
15385
  "An unknown session_id returns a bare {success:false} with 404 and no message."
15388
15386
  ]
@@ -17935,6 +17933,15 @@
17935
17933
  }
17936
17934
  ],
17937
17935
  "query": [
17936
+ {
17937
+ "name": "q[tags][]",
17938
+ "type": "array",
17939
+ "example": [
17940
+ "2FA",
17941
+ "delivery"
17942
+ ],
17943
+ "description": "Tag filter. Send it as a REPEATED BRACKETED parameter - `q[tags][]=2FA&q[tags][]=delivery` - one entry per tag name. Multiple tags are OR'd: a case matching any of them is returned. Verified on production 2026-09-23 against ground truth (12 of 280 cases tagged `2FA` -> the filter returns exactly those 12; two tags -> exactly the 22 in the union). DO NOT use `q[test_case_tags]`: it is accepted, returns 200, and is SILENTLY IGNORED - the full unfiltered set comes back looking like a successful search. A bare `q[tags]` carrying a JSON string matches nothing (0 rows), and `q[tags]` with some other shapes 500s."
17944
+ },
17938
17945
  {
17939
17946
  "name": "q[query]",
17940
17947
  "type": "string",
@@ -17995,12 +18002,6 @@
17995
18002
  "example": "3700",
17996
18003
  "description": "Comma-separated TM user ids; translated to BrowserStack ids server-side because reviews are stored against the BrowserStack id."
17997
18004
  },
17998
- {
17999
- "name": "q[test_case_tags]",
18000
- "type": "string",
18001
- "example": "[\"regression\",\"smoke\"]",
18002
- "description": "Tag filter. Must be a JSON-ENCODED STRING containing an array of tag names, not a bare comma list and not a real array. `q[tags]` is not a recognised key here and is dropped."
18003
- },
18004
18005
  {
18005
18006
  "name": "q[created_at]",
18006
18007
  "type": "string",
@@ -18156,6 +18157,7 @@
18156
18157
  }
18157
18158
  },
18158
18159
  "guidance": [
18160
+ "TAG FILTERING USES `q[tags][]`, REPEATED ONCE PER TAG. The previously documented `q[test_case_tags]` does not filter at all - it returns 200 and the complete unfiltered list, which reads as 'no tag filtering available'. Corrected 2026-09-23 after measuring both against ground truth on production.",
18159
18161
  "Four entity values route here: test-cases, test-runs, trtc (the test cases inside one test run) and test-plans. Anything else — 'reports' included — does not match the route constraint and is a 404 before the API is reached.",
18160
18162
  "entity=test-plans is accepted by the route and by the controller but has no implementation behind it: it always renders {test_plans: [], info: null} with a 200. It is a dead branch, not an empty result — use the test-plan listing capabilities instead.",
18161
18163
  "entity=trtc additionally requires a `test_run_id` query param. Without it the run filter is built from 0 and you get an empty page rather than an error.",
@@ -18431,6 +18433,7 @@
18431
18433
  ],
18432
18434
  "intent": "Search for entities within a project (v2 - POST with body)",
18433
18435
  "guidance": [
18436
+ "Tag and status filtering WORK here. Send `tags` (and `status`) as ordinary body fields - their json_path maps them under `q`, which is the shape the server wants. Verified through bind on production 2026-09-23: tags:['2FA'] returns exactly the 12 tagged cases out of 280.",
18434
18437
  "The general way to find cases by attribute. Ask for a large page when collecting ids to feed into another call, so the follow-up gets the whole set rather than the first page."
18435
18438
  ],
18436
18439
  "shape": "discovered",
@@ -20771,6 +20774,7 @@
20771
20774
  ],
20772
20775
  "intent": "Rename a template, enable or disable it, make it the workspace default, or replace its description and property field layout.",
20773
20776
  "guidance": [
20777
+ "A name-only PATCH works and returns a bare {success:true} - unlike the create, the update does NOT echo the template back, so re-read it if you need the result. Verified on production 2026-09-23.",
20774
20778
  "Sparse update: only the keys actually present in the body are touched, everything else keeps its current value. Sending an empty body is accepted and changes nothing.",
20775
20779
  "Field arrays REPLACE, they do not merge. Templates have no version history in this product, so the previous layout cannot be restored — read the template, edit the returned arrays, and send them back whole.",
20776
20780
  "Editing the field layout changes the fields shown on every test case using this template, in every linked project. Establish how many projects are affected before writing.",
@@ -28601,6 +28605,256 @@
28601
28605
  "$response": "InternalServerError"
28602
28606
  }
28603
28607
  }
28608
+ },
28609
+ {
28610
+ "name": "create_shared_component_v1",
28611
+ "method": "POST",
28612
+ "path": "/api/v1/projects/{project_id}/shared-components",
28613
+ "mode": "write",
28614
+ "entity": "shared_field",
28615
+ "path_params": [
28616
+ {
28617
+ "name": "project_id",
28618
+ "type": "integer",
28619
+ "required": true,
28620
+ "description": "Numeric id of the project the shared field belongs to."
28621
+ }
28622
+ ],
28623
+ "body": [
28624
+ {
28625
+ "name": "title",
28626
+ "type": "string",
28627
+ "required": true,
28628
+ "description": "Name of the block, shown in the Shared Fields list."
28629
+ },
28630
+ {
28631
+ "name": "field_type",
28632
+ "type": "string",
28633
+ "values": [
28634
+ "step",
28635
+ "background",
28636
+ "precondition"
28637
+ ],
28638
+ "example": "background",
28639
+ "description": "Which kind to create. OMITTING IT SILENTLY CREATES A `step` - it is not rejected, so a caller meaning to add a background or a precondition and forgetting this key gets a shared step instead, with no error to notice."
28640
+ },
28641
+ {
28642
+ "name": "shared_step_details",
28643
+ "type": "array",
28644
+ "required": true,
28645
+ "description": "The ordered rows making up the block. REQUIRED. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.",
28646
+ "fields": [
28647
+ {
28648
+ "name": "order",
28649
+ "type": "integer",
28650
+ "required": true,
28651
+ "description": "1-based position. Required on every row."
28652
+ },
28653
+ {
28654
+ "name": "step",
28655
+ "type": "string",
28656
+ "description": "The step text, the Gherkin background line, or the precondition text depending on field_type."
28657
+ },
28658
+ {
28659
+ "name": "result",
28660
+ "type": "string",
28661
+ "description": "Expected result; send an empty string when the kind has no result."
28662
+ },
28663
+ {
28664
+ "name": "test_data",
28665
+ "type": "string",
28666
+ "description": "Additional data for the row."
28667
+ }
28668
+ ]
28669
+ },
28670
+ {
28671
+ "name": "tags",
28672
+ "type": "array",
28673
+ "items": {
28674
+ "type": "string"
28675
+ },
28676
+ "description": "Plain tag name strings on the block itself."
28677
+ },
28678
+ {
28679
+ "name": "folder_id",
28680
+ "type": "integer",
28681
+ "nullable": true,
28682
+ "description": "Collection to file it under. Omit or send null for the Unassigned root."
28683
+ }
28684
+ ],
28685
+ "intent": "Create a reusable shared field - a step sequence, a Gherkin background block, or a precondition - that many test cases can then reference.",
28686
+ "returns": [
28687
+ "success",
28688
+ "data",
28689
+ "id",
28690
+ "title",
28691
+ "field_type",
28692
+ "test_case_count",
28693
+ "step_count",
28694
+ "tags",
28695
+ "folder_id",
28696
+ "created_by",
28697
+ "updated_by",
28698
+ "shared_step_details"
28699
+ ],
28700
+ "responses": {
28701
+ "200": {
28702
+ "$response": "SharedComponentWriteResponse"
28703
+ },
28704
+ "400": {
28705
+ "$response": "BadRequest"
28706
+ },
28707
+ "401": {
28708
+ "$response": "Unauthorized"
28709
+ },
28710
+ "403": {
28711
+ "$response": "Forbidden"
28712
+ },
28713
+ "404": {
28714
+ "$response": "NotFound"
28715
+ },
28716
+ "422": {
28717
+ "$response": "UnprocessableEntity"
28718
+ },
28719
+ "500": {
28720
+ "$response": "InternalServerError"
28721
+ }
28722
+ },
28723
+ "guidance": [
28724
+ "ONE CAPABILITY, THREE WORKFLOWS. `field_type` is the only difference between creating a shared step, a Gherkin background and a precondition - they are the same record and the same endpoint, not three features.",
28725
+ "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.",
28726
+ "`field_type` defaults to `step`. There is no error for omitting it, so a create meant as a background or precondition succeeds as the wrong kind. Send it explicitly every time.",
28727
+ "Sibling of create_shared_step_v1 on /shared-steps, which creates the same record but cannot set `field_type`. Use this route when the kind matters.",
28728
+ "The response echoes `shared_step_details` with each row's assigned id; the LISTING returns only `step_count`. Read the echo if you need row ids.",
28729
+ "Verified on preprod 2026-09-22: all three field_type values create successfully and list back under the matching field_type filter."
28730
+ ]
28731
+ },
28732
+ {
28733
+ "name": "update_shared_component_v1",
28734
+ "method": "PATCH",
28735
+ "path": "/api/v1/projects/{project_id}/shared-components/{shared_component_id}",
28736
+ "mode": "write",
28737
+ "entity": "shared_field",
28738
+ "path_params": [
28739
+ {
28740
+ "name": "project_id",
28741
+ "type": "integer",
28742
+ "required": true,
28743
+ "description": "Numeric id of the project."
28744
+ },
28745
+ {
28746
+ "name": "shared_component_id",
28747
+ "type": "integer",
28748
+ "required": true,
28749
+ "description": "Id of the shared field to change, from get_shared_components_v1."
28750
+ }
28751
+ ],
28752
+ "body": [
28753
+ {
28754
+ "name": "title",
28755
+ "type": "string",
28756
+ "required": true,
28757
+ "description": "Name of the block. REQUIRED ON EVERY CALL even when you are only changing rows - sending rows alone is a 400."
28758
+ },
28759
+ {
28760
+ "name": "field_type",
28761
+ "type": "string",
28762
+ "values": [
28763
+ "step",
28764
+ "background",
28765
+ "precondition"
28766
+ ],
28767
+ "description": "Which kind the block is. Send its existing value unless you intend to change it."
28768
+ },
28769
+ {
28770
+ "name": "shared_step_details",
28771
+ "type": "array",
28772
+ "required": true,
28773
+ "description": "The FULL replacement row list, not a patch - rows you leave out are removed. REQUIRED ON EVERY CALL even when you are only renaming: sending `title` alone is a 400. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.",
28774
+ "fields": [
28775
+ {
28776
+ "name": "order",
28777
+ "type": "integer",
28778
+ "required": true,
28779
+ "description": "1-based position. Required on every row."
28780
+ },
28781
+ {
28782
+ "name": "step",
28783
+ "type": "string",
28784
+ "description": "The step text, the Gherkin background line, or the precondition text depending on field_type."
28785
+ },
28786
+ {
28787
+ "name": "result",
28788
+ "type": "string",
28789
+ "description": "Expected result; send an empty string when the kind has no result."
28790
+ },
28791
+ {
28792
+ "name": "test_data",
28793
+ "type": "string",
28794
+ "description": "Additional data for the row."
28795
+ }
28796
+ ]
28797
+ },
28798
+ {
28799
+ "name": "tags",
28800
+ "type": "array",
28801
+ "items": {
28802
+ "type": "string"
28803
+ },
28804
+ "description": "Plain tag name strings; the list you send replaces the block's tags."
28805
+ },
28806
+ {
28807
+ "name": "folder_id",
28808
+ "type": "integer",
28809
+ "nullable": true,
28810
+ "description": "Collection to move it into."
28811
+ }
28812
+ ],
28813
+ "intent": "Change an existing shared field - its title, rows, tags or collection - with the edit propagating to every test case that references it.",
28814
+ "returns": [
28815
+ "success",
28816
+ "data",
28817
+ "id",
28818
+ "title",
28819
+ "field_type",
28820
+ "test_case_count",
28821
+ "step_count",
28822
+ "tags",
28823
+ "folder_id",
28824
+ "created_by",
28825
+ "updated_by",
28826
+ "shared_step_details"
28827
+ ],
28828
+ "responses": {
28829
+ "200": {
28830
+ "$response": "SharedComponentWriteResponse"
28831
+ },
28832
+ "400": {
28833
+ "$response": "BadRequest"
28834
+ },
28835
+ "401": {
28836
+ "$response": "Unauthorized"
28837
+ },
28838
+ "403": {
28839
+ "$response": "Forbidden"
28840
+ },
28841
+ "404": {
28842
+ "$response": "NotFound"
28843
+ },
28844
+ "422": {
28845
+ "$response": "UnprocessableEntity"
28846
+ },
28847
+ "500": {
28848
+ "$response": "InternalServerError"
28849
+ }
28850
+ },
28851
+ "guidance": [
28852
+ "BOTH `title` AND `shared_step_details` ARE REQUIRED ON EVERY CALL. Verified on preprod 2026-09-22: title alone -> 400, rows alone -> 400, both together -> 200. There is no partial update here despite the PATCH verb.",
28853
+ "`shared_step_details` REPLACES the row list. Read the block first and send back every row you want to keep, each with its `order`.",
28854
+ "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.",
28855
+ "The edit propagates to every test case referencing the block - `test_case_count` tells you how many that is before you change it.",
28856
+ "Sibling of update_shared_step_v1 on /shared-steps, which edits the same record but cannot set `field_type`."
28857
+ ]
28604
28858
  }
28605
28859
  ],
28606
28860
  "schemas": {
@@ -33807,30 +34061,31 @@
33807
34061
  },
33808
34062
  "SharedComponent": {
33809
34063
  "type": "object",
33810
- "description": "A shared field (called a shared component by the API) as it appears in a project listing.",
33811
- "required": [
33812
- "id",
33813
- "title",
33814
- "field_type"
33815
- ],
34064
+ "description": "A shared field: a reusable content block held under Shared Fields and referenced from many test cases. The same record backs all three kinds - step sequence, Gherkin background and precondition - distinguished by `field_type`. These are the fields every route returns, including the LISTING.\n",
33816
34065
  "properties": {
33817
34066
  "id": {
33818
- "type": "integer"
34067
+ "type": "integer",
34068
+ "description": "Shared-field id, from the same id space as the shared-steps endpoints."
33819
34069
  },
33820
34070
  "title": {
33821
34071
  "type": "string"
33822
34072
  },
33823
34073
  "field_type": {
33824
34074
  "type": "string",
33825
- "description": "step, background or precondition."
33826
- },
33827
- "step_count": {
33828
- "type": "integer",
33829
- "description": "Number of steps inside this shared field."
34075
+ "values": [
34076
+ "step",
34077
+ "background",
34078
+ "precondition"
34079
+ ],
34080
+ "description": "Which kind of shared field this is. Defaults to `step` when the create omits it.",
34081
+ "example": "background"
33830
34082
  },
33831
34083
  "test_case_count": {
33832
34084
  "type": "integer",
33833
- "description": "Number of test cases embedding it - the blast radius of any change."
34085
+ "description": "How many test cases reference this block."
34086
+ },
34087
+ "step_count": {
34088
+ "type": "integer"
33834
34089
  },
33835
34090
  "tags": {
33836
34091
  "type": "array",
@@ -33840,8 +34095,7 @@
33840
34095
  },
33841
34096
  "created_by": {
33842
34097
  "type": "object",
33843
- "description": "Null when the author's user record can no longer be resolved.",
33844
- "nullable": true,
34098
+ "description": "Reduced user reference - id and full_name only, not the wider User shape other TM serializers return.",
33845
34099
  "properties": {
33846
34100
  "id": {
33847
34101
  "type": "integer"
@@ -33853,7 +34107,6 @@
33853
34107
  },
33854
34108
  "updated_by": {
33855
34109
  "type": "object",
33856
- "nullable": true,
33857
34110
  "properties": {
33858
34111
  "id": {
33859
34112
  "type": "integer"
@@ -42967,6 +43220,48 @@
42967
43220
  }
42968
43221
  }
42969
43222
  }
43223
+ },
43224
+ "SharedComponentDetail": {
43225
+ "type": "object",
43226
+ "description": "The shared field as echoed by a CREATE or UPDATE: the listed fields plus its collection and its actual rows. The LISTING returns neither - it gives `step_count` instead of the rows, and does not carry `folder_id` at all.\n",
43227
+ "allOf": [
43228
+ {
43229
+ "$schema": "SharedComponent"
43230
+ }
43231
+ ],
43232
+ "properties": {
43233
+ "folder_id": {
43234
+ "type": "integer",
43235
+ "nullable": true,
43236
+ "description": "Collection it is filed under; null for the Unassigned root."
43237
+ },
43238
+ "shared_step_details": {
43239
+ "type": "array",
43240
+ "description": "The ordered rows. Returned by the create and update echoes; the LISTING omits it and gives `step_count` instead.",
43241
+ "items": {
43242
+ "type": "object",
43243
+ "properties": {
43244
+ "id": {
43245
+ "type": "integer"
43246
+ },
43247
+ "step": {
43248
+ "type": "string"
43249
+ },
43250
+ "result": {
43251
+ "type": "string",
43252
+ "nullable": true
43253
+ },
43254
+ "test_data": {
43255
+ "type": "string",
43256
+ "nullable": true
43257
+ },
43258
+ "order": {
43259
+ "type": "integer"
43260
+ }
43261
+ }
43262
+ }
43263
+ }
43264
+ }
42970
43265
  }
42971
43266
  },
42972
43267
  "responses": {
@@ -46900,64 +47195,28 @@
46900
47195
  },
46901
47196
  "defects": {
46902
47197
  "type": "array",
47198
+ "description": "Defect links raised on this session's LOG ENTRIES. Session-level `issues` do NOT appear here.",
46903
47199
  "items": {
46904
47200
  "type": "object",
46905
- "description": "An issue-tracker link record.",
46906
47201
  "properties": {
46907
- "id": {
46908
- "type": "integer",
46909
- "description": "Id of the link record itself, not of the ticket."
46910
- },
46911
47202
  "issue_id": {
46912
47203
  "type": "string",
46913
- "description": "Ticket key in the external tracker.",
46914
- "example": "PROJ-123"
47204
+ "description": "Identifier in the external tracker.",
47205
+ "example": "PROBE-2"
46915
47206
  },
46916
47207
  "issue_type": {
46917
47208
  "type": "string",
46918
- "description": "Tracker the ticket lives in (e.g. jira, zoho).",
47209
+ "description": "Tracker type.",
46919
47210
  "example": "jira"
46920
47211
  },
46921
- "issue_status": {
46922
- "type": "string",
46923
- "nullable": true
46924
- },
46925
- "priority": {
46926
- "type": "string",
46927
- "nullable": true
46928
- },
46929
- "record_status": {
46930
- "type": "string",
46931
- "nullable": true
46932
- },
46933
- "entity_type": {
46934
- "type": "string",
46935
- "description": "What the ticket is linked to — ExploratorySession for a session-level link, ExploratorySessionLog for a link made on one log entry.",
46936
- "example": "ExploratorySessionLog"
46937
- },
46938
- "entity_id": {
46939
- "type": "integer",
46940
- "description": "Id of the session or log the link belongs to, interpreted per entity_type."
46941
- },
46942
- "entity_uuid": {
47212
+ "jira_id": {
46943
47213
  "type": "string",
46944
- "nullable": true
46945
- },
46946
- "project_id": {
46947
- "type": "integer"
46948
- },
46949
- "metadata": {
46950
- "type": "object",
46951
47214
  "nullable": true,
46952
- "description": "Free-form tracker metadata captured at link time — ticket url, title, display_category, issue_category."
47215
+ "description": "Repeat of issue_id for jira links."
46953
47216
  },
46954
47217
  "created_at": {
46955
47218
  "type": "string",
46956
47219
  "format": "date-time"
46957
- },
46958
- "updated_at": {
46959
- "type": "string",
46960
- "format": "date-time"
46961
47220
  }
46962
47221
  }
46963
47222
  }
@@ -48336,6 +48595,21 @@
48336
48595
  }
48337
48596
  }
48338
48597
  }
48598
+ },
48599
+ "SharedComponentWriteResponse": {
48600
+ "description": "The saved shared field, echoed back under `data`.",
48601
+ "schema": {
48602
+ "type": "object",
48603
+ "properties": {
48604
+ "success": {
48605
+ "type": "boolean",
48606
+ "example": true
48607
+ },
48608
+ "data": {
48609
+ "$schema": "SharedComponentDetail"
48610
+ }
48611
+ }
48612
+ }
48339
48613
  }
48340
48614
  },
48341
48615
  "paging": {
@@ -49825,6 +50099,6 @@
49825
50099
  ]
49826
50100
  }
49827
50101
  },
49828
- "version": "1.32"
50102
+ "version": "1.37"
49829
50103
  }
49830
50104
  }
@@ -20,7 +20,7 @@ import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
20
20
  import { fetchTransport } from "./egress.js";
21
21
  import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
22
22
  import { invoke } from "./resolve.js";
23
- import { ambiguousProducts, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
23
+ import { ambiguousProducts, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
24
24
  export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
25
25
  /**
26
26
  * The search-side twin of `user_permission`: did a human choose the product, or did you?
@@ -219,10 +219,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
219
219
  "It does NOT carry parameters or response shapes. Once you have picked one, call " +
220
220
  "describeCapability for its full contract, then invokeCapability. Fetching the " +
221
221
  "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
222
- "a search. Results are ranked and capped, and `truncated` says when more matched.", {
222
+ "a search. Results are ranked and capped. " +
223
+ "TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " +
224
+ "(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " +
225
+ "what you want when the task is 'what can this product do' rather than a lookup. " +
226
+ "TO PAGE, send `offset`: every response carries `offset` and, when more matched, " +
227
+ "`next_offset` — resend the SAME query with that value. `next_offset` is absent on " +
228
+ "the last page, so walk until it stops rather than comparing counts.", {
223
229
  query: z
224
230
  .string()
225
- .describe("What you are trying to do, in plain language."),
231
+ .describe("What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " +
232
+ "instead of search: every capability in the product, in a stable order, " +
233
+ "paginated — use it when you want to see the surface rather than find one thing."),
226
234
  entity: z
227
235
  .string()
228
236
  .optional()
@@ -257,13 +265,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
257
265
  .optional()
258
266
  .describe("Restrict to reads or writes. Omit to let the query decide."),
259
267
  limit: z.number().optional().describe("Max results (default 8)."),
268
+ offset: z
269
+ .number()
270
+ .optional()
271
+ .describe("Skip this many results — send the `next_offset` from the previous response " +
272
+ "with the SAME query to get the next page. Omit for the first page."),
260
273
  }, {
261
274
  title: "Search Capabilities",
262
275
  readOnlyHint: true,
263
276
  destructiveHint: false,
264
277
  idempotentHint: true,
265
278
  openWorldHint: false,
266
- }, async ({ query, entity, product, product_choice, mode, limit }) => {
279
+ }, async ({ query, entity, product, product_choice, mode, limit, offset }) => {
267
280
  track("searchCapability");
268
281
  // THE GATE. Nothing here can tell whether a human was asked — same as the write
269
282
  // gate, which also takes the caller's word. What it can do is refuse to answer a
@@ -274,7 +287,13 @@ export function addCapabilityRegistryTools(server, deps, config) {
274
287
  // both products claim. 27 of the 198 eval queries, against 135 if constituent words
275
288
  // counted. `entity` is an explicit narrowing, so a caller that named one has already
276
289
  // been specific enough and is not asked again.
277
- if (product_choice !== "user_confirmed" && !entity) {
290
+ // The ambiguity gate asks the USER which product a word belongs to. A browse query
291
+ // contains no word to be ambiguous about — `*` names nothing — so there is nothing
292
+ // to ask, and asking anyway would block the one query whose whole purpose is to
293
+ // show what a product contains.
294
+ if (product_choice !== "user_confirmed" &&
295
+ !entity &&
296
+ !isBrowseQuery(query)) {
278
297
  const ambiguity = ambiguousProducts(registry.index.products, query);
279
298
  if (ambiguity.products.length > 1) {
280
299
  // EVERYTHING NEEDED TO ASK, IN THE REFUSAL. Telling the agent to go and call
@@ -323,6 +342,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
323
342
  product,
324
343
  mode: mode,
325
344
  limit,
345
+ offset,
326
346
  });
327
347
  return ok({
328
348
  // NO build_id. It is provenance — for our logs and for cache busting — and
@@ -18,6 +18,14 @@ import { Capability, Mode, ProductIndex } from "./types.js";
18
18
  * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
19
19
  * all tokenize alike.
20
20
  */
21
+ /**
22
+ * Is this the browse wildcard rather than a search?
23
+ *
24
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
25
+ * everything", and treating only one of them as the wildcard means the other silently
26
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
27
+ */
28
+ export declare function isBrowseQuery(query?: string): boolean;
21
29
  export declare function terms(text: string | undefined): string[];
22
30
  /**
23
31
  * A term plus its naive singular variants.
@@ -72,6 +80,18 @@ export interface SearchResult {
72
80
  hits: SearchHit[];
73
81
  truncated: boolean;
74
82
  total_matched: number;
83
+ /**
84
+ * Where this page started, and where the next one begins.
85
+ *
86
+ * `truncated` has always said more matched than fit; nothing said how to reach it, so
87
+ * the only way past the first page was to raise `limit` and re-read everything already
88
+ * seen. `next_offset` is absent on the last page, which is what makes "walk until it
89
+ * stops" terminate without the caller comparing counts.
90
+ */
91
+ offset: number;
92
+ next_offset?: number;
93
+ /** True when the query was the browse wildcard rather than a search. */
94
+ browse?: boolean;
75
95
  /**
76
96
  * The best pre-penalty term score, and whether it is weak enough to doubt.
77
97
  *
@@ -146,4 +166,5 @@ export declare function searchCapabilities(products: Record<string, ProductIndex
146
166
  product?: string;
147
167
  mode?: Mode;
148
168
  limit?: number;
169
+ offset?: number;
149
170
  }): SearchResult;
@@ -111,6 +111,17 @@ const PLURAL_INTENT = new Set([
111
111
  * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
112
112
  * all tokenize alike.
113
113
  */
114
+ /**
115
+ * Is this the browse wildcard rather than a search?
116
+ *
117
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
118
+ * everything", and treating only one of them as the wildcard means the other silently
119
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
120
+ */
121
+ export function isBrowseQuery(query) {
122
+ const q = (query || "").trim().toLowerCase();
123
+ return q === "*" || q === "all";
124
+ }
114
125
  export function terms(text) {
115
126
  return [
116
127
  ...(text || "")
@@ -592,6 +603,39 @@ export function vocabularyOf(products, only) {
592
603
  }
593
604
  export function searchCapabilities(products, query, options = {}) {
594
605
  const limit = options.limit && options.limit > 0 ? options.limit : 8;
606
+ const offset = options.offset && options.offset > 0 ? Math.floor(options.offset) : 0;
607
+ // BROWSE MODE: `*` lists the product instead of searching it.
608
+ //
609
+ // Scoring keeps only capabilities with matched > 0, so there was no query that returned
610
+ // everything — an agent that wanted to see what exists had to guess words until the
611
+ // shortlist looked complete, and could never tell when it was. `*` skips scoring
612
+ // entirely and pages the filtered set in a STABLE order (name, then path), because a
613
+ // ranked order is not a safe thing to paginate: ranking is query-dependent and ties
614
+ // break arbitrarily, so page 2 of a ranked list can repeat or skip rows. Browse is
615
+ // ordered by identity, so offset means the same thing on every call.
616
+ if (isBrowseQuery(query)) {
617
+ const all = Object.entries(products)
618
+ .filter(([name]) => !options.product || name === options.product)
619
+ .flatMap(([name, bundle]) => bundle.capabilities
620
+ .filter((capability) => (!options.entity || capability.entity === options.entity) &&
621
+ (!options.mode || capability.mode === options.mode))
622
+ .map((capability) => ({ product: name, capability })))
623
+ .sort((a, b) => (a.capability.name || "").localeCompare(b.capability.name || "") ||
624
+ a.capability.path.localeCompare(b.capability.path));
625
+ const page = all.slice(offset, offset + limit);
626
+ const end = offset + page.length;
627
+ return {
628
+ hits: page,
629
+ truncated: end < all.length,
630
+ total_matched: all.length,
631
+ top_matched: 0,
632
+ coverage: 1,
633
+ weak: false,
634
+ offset,
635
+ ...(end < all.length ? { next_offset: end } : {}),
636
+ browse: true,
637
+ };
638
+ }
595
639
  // Forms are computed once per query, not per capability: 173 capabilities x 6 haystacks
596
640
  // would otherwise rebuild the same handful of strings a thousand times.
597
641
  const wanted = terms(query).map(termForms);
@@ -650,12 +694,14 @@ export function searchCapabilities(products, query, options = {}) {
650
694
  // products and across `product`/`entity` narrowing — which the raw score is not.
651
695
  const perfect = weights.reduce((sum, w) => sum + IDENTITY_WEIGHT * w, 0);
652
696
  const coverage = perfect > 0 ? topMatched / perfect : 1;
697
+ const page = scored.slice(offset, offset + limit);
698
+ const end = offset + page.length;
653
699
  return {
654
- hits: scored
655
- .slice(0, limit)
656
- .map(({ product, capability }) => ({ product, capability })),
657
- truncated: scored.length > limit,
700
+ hits: page.map(({ product, capability }) => ({ product, capability })),
701
+ truncated: end < scored.length,
658
702
  total_matched: scored.length,
703
+ offset,
704
+ ...(end < scored.length ? { next_offset: end } : {}),
659
705
  top_matched: topMatched,
660
706
  coverage,
661
707
  weak: wanted.length > 0 && perfect > 0 && coverage < WEAK_COVERAGE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.5.0-beta.18",
3
+ "version": "1.5.0-beta.19",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",