@browserstack/mcp-server 1.2.15-beta.2 → 1.2.15-beta.3

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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared helpers for the /build-items endpoint.
3
+ *
4
+ * Pagination: the API returns everything when page[limit] is omitted, but may
5
+ * still paginate (meta.pagination.has_more + next_cursor) on very large
6
+ * builds or if server-side defaults change. fetchAllBuildItems handles both:
7
+ * it starts with an unbounded request and follows next_cursor if the response
8
+ * is paginated anyway.
9
+ */
10
+ import { BrowserStackConfig } from "../types.js";
11
+ export interface BuildItemsResult {
12
+ items: any[];
13
+ /** True if pagination could not be exhausted (page cap or cursor stall). */
14
+ truncated: boolean;
15
+ }
16
+ /**
17
+ * Fetch ALL build items for the given filters, following
18
+ * meta.pagination.next_cursor until has_more is false.
19
+ */
20
+ export declare function fetchAllBuildItems(config: BrowserStackConfig, baseParams: Record<string, string | string[]>): Promise<BuildItemsResult>;
21
+ /**
22
+ * Filter params that make filter[category]=changed actually return results.
23
+ * The API only maps the changed category to review states via
24
+ * filter[subcategories][]; without it the scope resolves to zero rows.
25
+ */
26
+ export declare const CHANGED_CATEGORY_PARAMS: Record<string, string | string[]>;
27
+ /**
28
+ * Format a 0..1 diff ratio as a percentage without hiding small diffs:
29
+ * 0 → "0%", tiny → "<0.01%", otherwise two decimals (e.g. "0.02%").
30
+ */
31
+ export declare function formatDiffPercent(ratio: number | null | undefined): string;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared helpers for the /build-items endpoint.
3
+ *
4
+ * Pagination: the API returns everything when page[limit] is omitted, but may
5
+ * still paginate (meta.pagination.has_more + next_cursor) on very large
6
+ * builds or if server-side defaults change. fetchAllBuildItems handles both:
7
+ * it starts with an unbounded request and follows next_cursor if the response
8
+ * is paginated anyway.
9
+ */
10
+ import { percyGet } from "./percy-auth.js";
11
+ const MAX_PAGES = 50;
12
+ /**
13
+ * Fetch ALL build items for the given filters, following
14
+ * meta.pagination.next_cursor until has_more is false.
15
+ */
16
+ export async function fetchAllBuildItems(config, baseParams) {
17
+ const items = [];
18
+ let cursor;
19
+ let pages = 0;
20
+ for (;;) {
21
+ const params = { ...baseParams };
22
+ if (cursor)
23
+ params["page[cursor]"] = cursor;
24
+ const response = await percyGet("/build-items", config, params);
25
+ items.push(...(response?.data || []));
26
+ pages += 1;
27
+ const pagination = response?.meta?.pagination;
28
+ const nextCursor = pagination?.next_cursor;
29
+ if (!pagination?.has_more || !nextCursor) {
30
+ return { items, truncated: false };
31
+ }
32
+ // Guard against a stalled cursor or runaway loop.
33
+ if (nextCursor === cursor || pages >= MAX_PAGES) {
34
+ return { items, truncated: true };
35
+ }
36
+ cursor = String(nextCursor);
37
+ }
38
+ }
39
+ /**
40
+ * Filter params that make filter[category]=changed actually return results.
41
+ * The API only maps the changed category to review states via
42
+ * filter[subcategories][]; without it the scope resolves to zero rows.
43
+ */
44
+ export const CHANGED_CATEGORY_PARAMS = {
45
+ "filter[category]": "changed",
46
+ "filter[subcategories][]": ["unreviewed", "changes_requested", "approved"],
47
+ };
48
+ /**
49
+ * Format a 0..1 diff ratio as a percentage without hiding small diffs:
50
+ * 0 → "0%", tiny → "<0.01%", otherwise two decimals (e.g. "0.02%").
51
+ */
52
+ export function formatDiffPercent(ratio) {
53
+ if (ratio == null)
54
+ return "—";
55
+ const pct = ratio * 100;
56
+ if (pct === 0)
57
+ return "0%";
58
+ if (pct < 0.01)
59
+ return "<0.01%";
60
+ return pct.toFixed(2) + "%";
61
+ }
@@ -22,7 +22,7 @@ export declare function getPercyTokenHeaders(token: string): Record<string, stri
22
22
  /**
23
23
  * Make a GET request to Percy API with Basic Auth.
24
24
  */
25
- export declare function percyGet(path: string, config: BrowserStackConfig, params?: Record<string, string>): Promise<any>;
25
+ export declare function percyGet(path: string, config: BrowserStackConfig, params?: Record<string, string | string[]>): Promise<any>;
26
26
  /**
27
27
  * Make a POST request to Percy API with Basic Auth.
28
28
  */
@@ -42,7 +42,12 @@ export async function percyGet(path, config, params) {
42
42
  const url = new URL(`${PERCY_API_BASE}${path}`);
43
43
  if (params) {
44
44
  for (const [key, value] of Object.entries(params)) {
45
- url.searchParams.set(key, value);
45
+ if (Array.isArray(value)) {
46
+ value.forEach((v) => url.searchParams.append(key, v));
47
+ }
48
+ else {
49
+ url.searchParams.set(key, value);
50
+ }
46
51
  }
47
52
  }
48
53
  const response = await fetch(url.toString(), { headers });
@@ -11,6 +11,7 @@
11
11
  * - snapshots: all snapshots with review states
12
12
  */
13
13
  import { percyGet, percyPost } from "../../../lib/percy-api/percy-auth.js";
14
+ import { fetchAllBuildItems, formatDiffPercent, CHANGED_CATEGORY_PARAMS, } from "../../../lib/percy-api/build-items.js";
14
15
  import { setActiveBuild } from "../../../lib/percy-api/percy-session.js";
15
16
  export async function percyGetBuildDetail(args, config) {
16
17
  const detail = args.detail || "overview";
@@ -253,12 +254,10 @@ async function getAiSummary(buildId, config) {
253
254
  }
254
255
  // ── Changes ─────────────────────────────────────────────────────────────────
255
256
  async function getChanges(buildId, config) {
256
- const response = await percyGet("/build-items", config, {
257
+ const { items, truncated } = await fetchAllBuildItems(config, {
257
258
  "filter[build-id]": buildId,
258
- "filter[category]": "changed",
259
- "page[limit]": "30",
259
+ ...CHANGED_CATEGORY_PARAMS,
260
260
  });
261
- const items = response?.data || [];
262
261
  if (!items.length) {
263
262
  return {
264
263
  content: [
@@ -275,14 +274,15 @@ async function getChanges(buildId, config) {
275
274
  const a = item.attributes || item;
276
275
  const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?";
277
276
  const displayName = a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || "";
278
- const diff = (a["max-diff-ratio"] ?? a.maxDiffRatio) != null
279
- ? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%"
280
- : "—";
277
+ const diff = formatDiffPercent(a["max-diff-ratio"] ?? a.maxDiffRatio);
281
278
  const bugs = a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? 0;
282
279
  const review = a["review-state"] || a.reviewState || "?";
283
280
  const count = a["item-count"] || a.itemCount || 1;
284
281
  output += `| ${i + 1} | ${name} | ${displayName || "—"} | ${diff} | ${bugs} | ${review} | ${count} |\n`;
285
282
  });
283
+ if (truncated) {
284
+ output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
285
+ }
286
286
  output += `\nUse \`percy_get_snapshot\` with a snapshot ID from above for full details.\n`;
287
287
  return { content: [{ type: "text", text: output }] };
288
288
  }
@@ -523,11 +523,9 @@ async function getNetwork(args, config) {
523
523
  }
524
524
  // ── Snapshots ───────────────────────────────────────────────────────────────
525
525
  async function getSnapshots(buildId, config) {
526
- const response = await percyGet("/build-items", config, {
526
+ const { items, truncated } = await fetchAllBuildItems(config, {
527
527
  "filter[build-id]": buildId,
528
- "page[limit]": "30",
529
528
  });
530
- const items = response?.data || [];
531
529
  if (!items.length) {
532
530
  return {
533
531
  content: [
@@ -550,9 +548,7 @@ async function getSnapshots(buildId, config) {
550
548
  const a = item.attributes || item;
551
549
  const name = a["cover-snapshot-name"] || a.coverSnapshotName || "?";
552
550
  const display = a["cover-snapshot-display-name"] || a.coverSnapshotDisplayName || "—";
553
- const diff = (a["max-diff-ratio"] ?? a.maxDiffRatio) != null
554
- ? ((a["max-diff-ratio"] ?? a.maxDiffRatio) * 100).toFixed(1) + "%"
555
- : "—";
551
+ const diff = formatDiffPercent(a["max-diff-ratio"] ?? a.maxDiffRatio);
556
552
  const bugs = a["max-bug-total-potential-bugs"] ?? a.maxBugTotalPotentialBugs ?? "—";
557
553
  const review = a["review-state"] || a.reviewState || "?";
558
554
  const count = a["item-count"] || a.itemCount || 1;
@@ -562,6 +558,9 @@ async function getSnapshots(buildId, config) {
562
558
  const more = (a["snapshot-ids"] || a.snapshotIds || []).length > 3 ? "..." : "";
563
559
  output += `| ${i + 1} | ${name} | ${display} | ${diff} | ${bugs} | ${review} | ${count} | ${snapIds}${more} |\n`;
564
560
  });
561
+ if (truncated) {
562
+ output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
563
+ }
565
564
  output += `\nUse \`percy_get_snapshot\` with a snapshot ID for full comparison details.\n`;
566
565
  return { content: [{ type: "text", text: output }] };
567
566
  }
@@ -1,4 +1,5 @@
1
1
  import { percyGet } from "../../../lib/percy-api/percy-auth.js";
2
+ import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
2
3
  export async function percyGetComparison(args, config) {
3
4
  const response = await percyGet(`/comparisons/${args.comparison_id}`, config, {
4
5
  include: [
@@ -25,8 +26,8 @@ export async function percyGetComparison(args, config) {
25
26
  output += `| **Browser** | ${browserName} |\n`;
26
27
  output += `| **Width** | ${attrs.width || "?"}px |\n`;
27
28
  output += `| **State** | ${attrs.state || "?"} |\n`;
28
- output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
29
- output += `| **AI diff ratio** | ${attrs["ai-diff-ratio"] != null ? (attrs["ai-diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
29
+ output += `| **Diff ratio** | ${formatDiffPercent(attrs["diff-ratio"])} |\n`;
30
+ output += `| **AI diff ratio** | ${formatDiffPercent(attrs["ai-diff-ratio"])} |\n`;
30
31
  output += `| **AI state** | ${attrs["ai-processing-state"] || "—"} |\n`;
31
32
  output += `| **Potential bugs** | ${ai["total-potential-bugs"] ?? "—"} |\n`;
32
33
  output += `| **AI visual diffs** | ${ai["total-ai-visual-diffs"] ?? "—"} |\n`;
@@ -1,4 +1,5 @@
1
1
  import { percyGet } from "../../../lib/percy-api/percy-auth.js";
2
+ import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
2
3
  export async function percyGetSnapshot(args, config) {
3
4
  const response = await percyGet(`/snapshots/${args.snapshot_id}`, config, {
4
5
  include: [
@@ -19,7 +20,7 @@ export async function percyGetSnapshot(args, config) {
19
20
  }
20
21
  output += `| Field | Value |\n|---|---|\n`;
21
22
  output += `| **Review** | ${attrs["review-state"] || "—"} (${attrs["review-state-reason"] || "—"}) |\n`;
22
- output += `| **Diff ratio** | ${attrs["diff-ratio"] != null ? (attrs["diff-ratio"] * 100).toFixed(2) + "%" : "—"} |\n`;
23
+ output += `| **Diff ratio** | ${formatDiffPercent(attrs["diff-ratio"])} |\n`;
23
24
  output += `| **Test case** | ${attrs["test-case-name"] || "none"} |\n`;
24
25
  output += `| **Comments** | ${attrs["total-open-comments"] ?? 0} |\n`;
25
26
  output += `| **Layout** | ${attrs["enable-layout"] ? "enabled" : "disabled"} |\n`;
@@ -45,12 +46,8 @@ export async function percyGetSnapshot(args, config) {
45
46
  const ca = c.attributes || {};
46
47
  const browserId = c.relationships?.browser?.data?.id;
47
48
  const browserName = browsers.get(browserId) || "?";
48
- const diff = ca["diff-ratio"] != null
49
- ? (ca["diff-ratio"] * 100).toFixed(1) + "%"
50
- : "—";
51
- const aiDiff = ca["ai-diff-ratio"] != null
52
- ? (ca["ai-diff-ratio"] * 100).toFixed(1) + "%"
53
- : "—";
49
+ const diff = formatDiffPercent(ca["diff-ratio"]);
50
+ const aiDiff = formatDiffPercent(ca["ai-diff-ratio"]);
54
51
  const aiState = ca["ai-processing-state"] || "—";
55
52
  const bugs = ca["ai-details"]?.["total-potential-bugs"] ?? "—";
56
53
  output += `| ${browserName} | ${ca.width || "?"}px | ${diff} | ${aiDiff} | ${aiState} | ${bugs} |\n`;
@@ -1,27 +1,40 @@
1
1
  import { percyGet } from "../../../lib/percy-api/percy-auth.js";
2
+ import { fetchAllBuildItems, formatDiffPercent, CHANGED_CATEGORY_PARAMS, } from "../../../lib/percy-api/build-items.js";
2
3
  export async function percySearchBuildItems(args, config) {
3
- const params = { "filter[build-id]": args.build_id };
4
- if (args.category)
4
+ const params = {
5
+ "filter[build-id]": args.build_id,
6
+ };
7
+ if (args.category === "changed") {
8
+ // The API resolves the changed category through subcategories; without
9
+ // them filter[category]=changed matches nothing.
10
+ Object.assign(params, CHANGED_CATEGORY_PARAMS);
11
+ }
12
+ else if (args.category) {
5
13
  params["filter[category]"] = args.category;
14
+ }
6
15
  if (args.sort_by)
7
16
  params["filter[sort_by]"] = args.sort_by;
8
- if (args.limit)
9
- params["page[limit]"] = String(args.limit);
10
17
  // Array filters
11
18
  if (args.browser_ids)
12
- args.browser_ids.split(",").forEach((id) => {
13
- params[`filter[browser_ids][]`] = id.trim();
14
- });
19
+ params["filter[browser_ids][]"] = args.browser_ids
20
+ .split(",")
21
+ .map((id) => id.trim());
15
22
  if (args.widths)
16
- args.widths.split(",").forEach((w) => {
17
- params[`filter[widths][]`] = w.trim();
18
- });
23
+ params["filter[widths][]"] = args.widths.split(",").map((w) => w.trim());
19
24
  if (args.os)
20
25
  params["filter[os]"] = args.os;
21
26
  if (args.device_name)
22
27
  params["filter[device_name]"] = args.device_name;
23
- const response = await percyGet("/build-items", config, params);
24
- const items = response?.data || [];
28
+ let items;
29
+ let truncated = false;
30
+ if (args.limit) {
31
+ params["page[limit]"] = String(args.limit);
32
+ const response = await percyGet("/build-items", config, params);
33
+ items = response?.data || [];
34
+ }
35
+ else {
36
+ ({ items, truncated } = await fetchAllBuildItems(config, params));
37
+ }
25
38
  if (!items.length) {
26
39
  return {
27
40
  content: [
@@ -34,12 +47,13 @@ export async function percySearchBuildItems(args, config) {
34
47
  items.forEach((item, i) => {
35
48
  const attrs = item.attributes || item;
36
49
  const name = attrs.coverSnapshotName || attrs["cover-snapshot-name"] || "?";
37
- const diff = attrs.maxDiffRatio != null
38
- ? `${(attrs.maxDiffRatio * 100).toFixed(1)}%`
39
- : "—";
50
+ const diff = formatDiffPercent(attrs.maxDiffRatio ?? attrs["max-diff-ratio"]);
40
51
  const review = attrs.reviewState || attrs["review-state"] || "?";
41
52
  const count = attrs.itemCount || attrs["item-count"] || 1;
42
53
  output += `| ${i + 1} | ${name} | ${diff} | ${review} | ${count} |\n`;
43
54
  });
55
+ if (truncated) {
56
+ output += `\n⚠️ Result may be incomplete — pagination could not be fully exhausted.\n`;
57
+ }
44
58
  return { content: [{ type: "text", text: output }] };
45
59
  }
@@ -1,4 +1,5 @@
1
1
  import { PercyClient } from "../../../lib/percy-api/client.js";
2
+ import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
2
3
  export async function percyAutoTriage(args, config) {
3
4
  const client = new PercyClient(config);
4
5
  const noiseThreshold = args.noise_threshold ?? 0.005; // 0.5%
@@ -43,14 +44,14 @@ export async function percyAutoTriage(args, config) {
43
44
  if (critical.length > 0) {
44
45
  output += `### CRITICAL — Potential Bugs (${critical.length})\n`;
45
46
  critical.forEach((e, i) => {
46
- output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s)\n`;
47
+ output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff, ${e.potentialBugs} bug(s)\n`;
47
48
  });
48
49
  output += "\n";
49
50
  }
50
51
  if (reviewRequired.length > 0) {
51
52
  output += `### REVIEW REQUIRED (${reviewRequired.length})\n`;
52
53
  reviewRequired.forEach((e, i) => {
53
- output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
54
+ output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff\n`;
54
55
  });
55
56
  output += "\n";
56
57
  }
@@ -1,4 +1,5 @@
1
1
  import { PercyClient } from "../../../lib/percy-api/client.js";
2
+ import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
2
3
  import { pollUntil } from "../../../lib/percy-api/polling.js";
3
4
  export async function percyDiffExplain(args, config) {
4
5
  const client = new PercyClient(config);
@@ -22,9 +23,9 @@ export async function percyDiffExplain(args, config) {
22
23
  // Basic diff info
23
24
  const diffRatio = comparison.diffRatio ?? 0;
24
25
  const aiDiffRatio = comparison.aiDiffRatio;
25
- output += `**Diff:** ${(diffRatio * 100).toFixed(1)}%`;
26
+ output += `**Diff:** ${formatDiffPercent(diffRatio)}`;
26
27
  if (aiDiffRatio !== null && aiDiffRatio !== undefined) {
27
- output += ` | **AI Diff:** ${(aiDiffRatio * 100).toFixed(1)}%`;
28
+ output += ` | **AI Diff:** ${formatDiffPercent(aiDiffRatio)}`;
28
29
  const reduction = diffRatio > 0 ? ((1 - aiDiffRatio / diffRatio) * 100).toFixed(0) : "0";
29
30
  output += ` (${reduction}% noise filtered)`;
30
31
  }
@@ -1,4 +1,5 @@
1
1
  import { PercyClient } from "../../../lib/percy-api/client.js";
2
+ import { formatDiffPercent } from "../../../lib/percy-api/build-items.js";
2
3
  import { percyCache } from "../../../lib/percy-api/cache.js";
3
4
  import { formatBuild } from "../../../lib/percy-api/formatter.js";
4
5
  export async function percyPrVisualReport(args, config) {
@@ -136,21 +137,21 @@ export async function percyPrVisualReport(args, config) {
136
137
  if (critical.length > 0) {
137
138
  output += `**CRITICAL — Potential Bugs (${critical.length}):**\n`;
138
139
  critical.forEach((e, i) => {
139
- output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff, ${e.potentialBugs} bug(s) flagged\n`;
140
+ output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff, ${e.potentialBugs} bug(s) flagged\n`;
140
141
  });
141
142
  output += "\n";
142
143
  }
143
144
  if (review.length > 0) {
144
145
  output += `**REVIEW REQUIRED (${review.length}):**\n`;
145
146
  review.forEach((e, i) => {
146
- output += `${i + 1}. **${e.name}** — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
147
+ output += `${i + 1}. **${e.name}** — ${formatDiffPercent(e.diffRatio)} diff\n`;
147
148
  });
148
149
  output += "\n";
149
150
  }
150
151
  if (expected.length > 0) {
151
152
  output += `**EXPECTED CHANGES (${expected.length}):**\n`;
152
153
  expected.forEach((e, i) => {
153
- output += `${i + 1}. ${e.name} — ${(e.diffRatio * 100).toFixed(1)}% diff\n`;
154
+ output += `${i + 1}. ${e.name} — ${formatDiffPercent(e.diffRatio)} diff\n`;
154
155
  });
155
156
  output += "\n";
156
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.2.15-beta.2",
3
+ "version": "1.2.15-beta.3",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",