Skip to content

Commit 29feef6

Browse files
DeRaowlclaude
andcommitted
feat(percy): add App Percy BYOS tool, session state, and richer tool responses
Add percy_create_app_build — creates App Percy builds by uploading device screenshots with proper device metadata (OS, orientation, screen size). Supports two modes: built-in sample data (3 devices × 2 screenshots, zero setup) and custom resources directory with device.json + PNGs. Add percy-session — in-memory state that persists active project token, build ID/URL/number, and org info across tool calls so subsequent commands get richer context automatically. Fix percy_auth_status — replace flaky /projects endpoint (500 error) with lightweight BrowserStack user API check. Show org info and getting started guide. Enhance all tool responses — every project command returns and activates the token, every build command returns Build ID, Build #, Build URL, and "Next Steps" with exact follow-up commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a59a55d commit 29feef6

17 files changed

Lines changed: 1107 additions & 70 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"dev": "tsx watch --clear-screen=false src/index.ts",
1616
"test": "vitest run",
1717
"lint": "eslint . --ext .ts",
18-
"format": "prettier --write \"src/**/*.ts\""
18+
"format": "prettier --write \"src/**/*.ts\"",
19+
"generate-app-samples": "node scripts/generate-app-samples.mjs"
1920
},
2021
"bin": {
2122
"browserstack-mcp-server": "dist/index.js"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Generated sample screenshots — run `npm run generate-app-samples` to create
2+
*.png
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"deviceName": "Pixel 7",
3+
"osName": "Android",
4+
"osVersion": "13",
5+
"orientation": "portrait",
6+
"deviceScreenSize": "1080x2400",
7+
"statusBarHeight": 118,
8+
"navBarHeight": 63
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"deviceName": "Samsung Galaxy S23",
3+
"osName": "Android",
4+
"osVersion": "13",
5+
"orientation": "portrait",
6+
"deviceScreenSize": "1080x2340",
7+
"statusBarHeight": 110,
8+
"navBarHeight": 63
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"deviceName": "iPhone 14 Pro",
3+
"osName": "iOS",
4+
"osVersion": "16",
5+
"orientation": "portrait",
6+
"deviceScreenSize": "1179x2556",
7+
"statusBarHeight": 132,
8+
"navBarHeight": 0
9+
}

scripts/generate-app-samples.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Generate sample PNG screenshots for App Percy BYOS testing.
4+
*
5+
* Reads device.json from each folder under resources/app-percy-samples/
6+
* and generates matching-dimension PNGs using sharp.
7+
*
8+
* Usage: node scripts/generate-app-samples.mjs
9+
*/
10+
11+
import { readdir, readFile, stat } from "fs/promises";
12+
import { join, dirname } from "path";
13+
import { fileURLToPath } from "url";
14+
import sharp from "sharp";
15+
16+
const __dirname = dirname(fileURLToPath(import.meta.url));
17+
const SAMPLES_DIR = join(__dirname, "..", "resources", "app-percy-samples");
18+
19+
const SCREENSHOT_NAMES = ["Home Screen", "Login Screen"];
20+
const COLORS = [
21+
{ r: 230, g: 230, b: 250 }, // lavender
22+
{ r: 230, g: 250, b: 230 }, // green
23+
{ r: 250, g: 240, b: 230 }, // peach
24+
];
25+
26+
async function main() {
27+
const entries = await readdir(SAMPLES_DIR);
28+
let colorIdx = 0;
29+
30+
for (const entry of entries) {
31+
const folderPath = join(SAMPLES_DIR, entry);
32+
const folderStat = await stat(folderPath);
33+
if (!folderStat.isDirectory()) continue;
34+
35+
const configPath = join(folderPath, "device.json");
36+
try {
37+
await stat(configPath);
38+
} catch {
39+
continue;
40+
}
41+
42+
const config = JSON.parse(await readFile(configPath, "utf-8"));
43+
const [width, height] = config.deviceScreenSize.split("x").map(Number);
44+
const bg = COLORS[colorIdx % COLORS.length];
45+
colorIdx++;
46+
47+
for (const name of SCREENSHOT_NAMES) {
48+
const filePath = join(folderPath, `${name}.png`);
49+
await sharp({
50+
create: { width, height, channels: 3, background: bg },
51+
})
52+
.png({ compressionLevel: 9 })
53+
.toFile(filePath);
54+
55+
console.log(` ✓ ${entry}/${name}.png (${width}×${height})`);
56+
}
57+
}
58+
59+
console.log("\nDone. Sample PNGs generated in resources/app-percy-samples/");
60+
}
61+
62+
main().catch((err) => {
63+
console.error("Failed:", err.message);
64+
process.exit(1);
65+
});

src/lib/percy-api/percy-error-handler.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,39 @@ export const TOOL_HELP: Record<string, ToolHelp> = {
265265
'Use percy_clone_build with source_build_id "48436286" and target_project_name "my-project"',
266266
],
267267
},
268+
percy_create_app_build: {
269+
name: "percy_create_app_build",
270+
description: "Create an App Percy BYOS build from device screenshots",
271+
params: [
272+
{
273+
name: "project_name",
274+
required: true,
275+
description: "App Percy project name",
276+
example: "my-mobile-app",
277+
},
278+
{
279+
name: "resources_dir",
280+
required: true,
281+
description: "Path to resources directory with device folders",
282+
example: "./resources",
283+
},
284+
{
285+
name: "branch",
286+
required: false,
287+
description: "Git branch (auto-detected)",
288+
example: "main",
289+
},
290+
{
291+
name: "test_case",
292+
required: false,
293+
description: "Test case name for all snapshots",
294+
example: "Login Flow",
295+
},
296+
],
297+
examples: [
298+
'Use percy_create_app_build with project_name "my-mobile-app" and resources_dir "./resources"',
299+
],
300+
},
268301
percy_get_insights: {
269302
name: "percy_get_insights",
270303
description: "Get testing health metrics",

src/lib/percy-api/percy-session.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Percy Session — In-memory state that persists across tool calls.
3+
*
4+
* Stores active project token, build context, and org info so
5+
* subsequent tool calls get richer context automatically.
6+
*/
7+
8+
export interface PercySessionState {
9+
// Active project
10+
projectName?: string;
11+
projectToken?: string;
12+
projectSlug?: string;
13+
projectId?: string;
14+
projectType?: string;
15+
16+
// Active build
17+
buildId?: string;
18+
buildNumber?: string;
19+
buildUrl?: string;
20+
buildBranch?: string;
21+
22+
// Org
23+
orgSlug?: string;
24+
orgId?: string;
25+
}
26+
27+
let session: PercySessionState = {};
28+
29+
// ── Setters ─────────────────────────────────────────────────────────────────
30+
31+
export function setActiveProject(opts: {
32+
name: string;
33+
token: string;
34+
slug?: string;
35+
id?: string;
36+
type?: string;
37+
}) {
38+
session.projectName = opts.name;
39+
session.projectToken = opts.token;
40+
if (opts.slug) session.projectSlug = opts.slug;
41+
if (opts.id) session.projectId = opts.id;
42+
if (opts.type) session.projectType = opts.type;
43+
}
44+
45+
export function setActiveBuild(opts: {
46+
id: string;
47+
number?: string;
48+
url?: string;
49+
branch?: string;
50+
}) {
51+
session.buildId = opts.id;
52+
if (opts.number) session.buildNumber = opts.number;
53+
if (opts.url) session.buildUrl = opts.url;
54+
if (opts.branch) session.buildBranch = opts.branch;
55+
}
56+
57+
export function setOrg(opts: { slug?: string; id?: string }) {
58+
if (opts.slug) session.orgSlug = opts.slug;
59+
if (opts.id) session.orgId = opts.id;
60+
}
61+
62+
// ── Getters ─────────────────────────────────────────────────────────────────
63+
64+
export function getSession(): PercySessionState {
65+
return { ...session };
66+
}
67+
68+
export function getActiveToken(): string | undefined {
69+
return session.projectToken;
70+
}
71+
72+
export function getActiveBuildId(): string | undefined {
73+
return session.buildId;
74+
}
75+
76+
// ── Formatters (append to tool output) ──────────────────────────────────────
77+
78+
export function formatActiveProject(): string {
79+
if (!session.projectName) return "";
80+
const masked = session.projectToken
81+
? `${session.projectToken.slice(0, 8)}...${session.projectToken.slice(-4)}`
82+
: "—";
83+
let out = `\n### Active Project\n\n`;
84+
out += `| | |\n|---|---|\n`;
85+
out += `| **Project** | ${session.projectName} |\n`;
86+
out += `| **Token** | \`${masked}\` |\n`;
87+
if (session.projectType) out += `| **Type** | ${session.projectType} |\n`;
88+
if (session.projectSlug) out += `| **Slug** | ${session.projectSlug} |\n`;
89+
return out;
90+
}
91+
92+
export function formatActiveBuild(): string {
93+
if (!session.buildId) return "";
94+
let out = `\n### Active Build\n\n`;
95+
out += `| | |\n|---|---|\n`;
96+
out += `| **Build ID** | ${session.buildId} |\n`;
97+
if (session.buildNumber)
98+
out += `| **Build #** | ${session.buildNumber} |\n`;
99+
if (session.buildUrl) out += `| **URL** | ${session.buildUrl} |\n`;
100+
if (session.buildBranch)
101+
out += `| **Branch** | ${session.buildBranch} |\n`;
102+
return out;
103+
}
104+
105+
export function formatSessionSummary(): string {
106+
const parts: string[] = [];
107+
if (session.projectName) {
108+
const masked = session.projectToken
109+
? `****${session.projectToken.slice(-4)}`
110+
: "";
111+
parts.push(
112+
`**Project:** ${session.projectName} (${masked})`,
113+
);
114+
}
115+
if (session.buildId) {
116+
parts.push(
117+
`**Build:** #${session.buildNumber || session.buildId}${session.buildUrl ? ` — ${session.buildUrl}` : ""}`,
118+
);
119+
}
120+
return parts.length > 0 ? parts.join(" | ") : "";
121+
}
Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import {
2-
percyGet,
3-
getOrCreateProjectToken,
4-
} from "../../../lib/percy-api/percy-auth.js";
1+
import { percyGet } from "../../../lib/percy-api/percy-auth.js";
52
import { BrowserStackConfig } from "../../../lib/types.js";
63
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
import {
5+
getSession,
6+
formatActiveProject,
7+
formatActiveBuild,
8+
} from "../../../lib/percy-api/percy-session.js";
79

810
export async function percyAuthStatusV2(
911
_args: Record<string, never>,
@@ -14,50 +16,86 @@ export async function percyAuthStatusV2(
1416
const hasCreds = !!(
1517
config["browserstack-username"] && config["browserstack-access-key"]
1618
);
17-
const percyToken = process.env.PERCY_TOKEN;
1819

1920
output += `| Credential | Status |\n|---|---|\n`;
2021
output += `| BrowserStack Username | ${hasCreds ? config["browserstack-username"] : "Not set"} |\n`;
2122
output += `| BrowserStack Access Key | ${hasCreds ? "Set" : "Not set"} |\n`;
22-
output += `| PERCY_TOKEN | ${percyToken ? `Set (****${percyToken.slice(-4)})` : "Not set"} |\n`;
2323
output += "\n";
2424

25-
// Test Basic Auth (this is what all read/write tools use)
25+
// Note about PERCY_TOKEN — it's per-project, not global
26+
output += `> **Note:** PERCY_TOKEN is set per-project, not globally. Use \`percy_create_project\` to get a project token — it will be activated automatically for subsequent calls.\n\n`;
27+
2628
if (hasCreds) {
2729
output += `### Validation\n\n`;
30+
31+
// Test BrowserStack API by checking user info (lightweight, won't 500)
32+
const bsAuth = Buffer.from(
33+
`${config["browserstack-username"]}:${config["browserstack-access-key"]}`,
34+
).toString("base64");
35+
2836
try {
29-
const response = await percyGet("/projects", config, {
30-
"page[limit]": "1",
31-
});
32-
const projects = response?.data || [];
33-
if (projects.length > 0) {
34-
output += `**Percy API (Basic Auth):** Connected — ${projects[0].attributes?.name || "project found"}\n`;
37+
const response = await fetch(
38+
"https://api.browserstack.com/api/app_percy/user",
39+
{ headers: { Authorization: `Basic ${bsAuth}` } },
40+
);
41+
if (response.ok) {
42+
const userData = await response.json();
43+
const orgName = userData?.organizations?.[0]?.name;
44+
const orgId = userData?.organizations?.[0]?.id;
45+
output += `**BrowserStack API:** Connected\n`;
46+
if (orgName) output += `**Organization:** ${orgName}`;
47+
if (orgId) output += ` (ID: ${orgId})`;
48+
output += `\n`;
3549
} else {
36-
output += `**Percy API (Basic Auth):** Connected — no projects yet\n`;
50+
output += `**BrowserStack API:** ${response.status} ${response.statusText}\n`;
3751
}
3852
} catch (e: any) {
39-
output += `**Percy API (Basic Auth):** Failed — ${e.message}\n`;
53+
output += `**BrowserStack API:** Failed — ${e.message}\n`;
4054
}
4155

42-
// Test BrowserStack project API
56+
// Test Percy API read access (use a lightweight endpoint)
4357
try {
44-
await getOrCreateProjectToken("__auth_check__", config);
45-
output += `**BrowserStack API:** Can create projects\n`;
58+
await percyGet("/organizations", config, { "page[limit]": "1" });
59+
output += `**Percy API (Basic Auth):** Connected\n`;
4660
} catch (e: any) {
47-
output += `**BrowserStack API:** Failed — ${e.message}\n`;
61+
// If /organizations fails, try a simpler endpoint
62+
try {
63+
await percyGet("/user", config);
64+
output += `**Percy API (Basic Auth):** Connected\n`;
65+
} catch {
66+
output += `**Percy API (Basic Auth):** Limited — ${e.message}\n`;
67+
output += `This is OK. All project-scoped tools work via BrowserStack API.\n`;
68+
}
4869
}
4970
}
5071

5172
output += "\n### Capabilities\n\n";
5273
if (hasCreds) {
53-
output += `- Create projects, builds, snapshots\n`;
74+
output += `All Percy MCP tools are available:\n`;
75+
output += `- Create/manage projects and tokens\n`;
76+
output += `- Create builds (URL, screenshot, app BYOS)\n`;
5477
output += `- Read builds, snapshots, comparisons\n`;
55-
output += `- Approve/reject builds\n`;
56-
output += `- All Percy MCP tools\n`;
78+
output += `- AI analysis, RCA, insights\n`;
79+
output += `- Clone builds, Figma integration\n`;
5780
} else {
58-
output += `No BrowserStack credentials. Run:\n`;
59-
output += `\`\`\`bash\ncd mcp-server && ./percy-config/setup.sh\n\`\`\`\n`;
81+
output += `No BrowserStack credentials found.\n\n`;
82+
output += `Set your credentials in MCP server config or environment:\n`;
83+
output += `\`\`\`bash\nexport BROWSERSTACK_USERNAME="your_username"\nexport BROWSERSTACK_ACCESS_KEY="your_key"\n\`\`\`\n`;
6084
}
6185

86+
// Show active session context
87+
const session = getSession();
88+
if (session.projectName || session.buildId) {
89+
output += `\n### Active Session\n`;
90+
output += formatActiveProject();
91+
output += formatActiveBuild();
92+
}
93+
94+
output += `\n### Getting Started\n\n`;
95+
output += `1. \`percy_create_project\` — Create/access a project (sets active token)\n`;
96+
output += `2. \`percy_create_build\` — Create a web build with URLs or screenshots\n`;
97+
output += `3. \`percy_create_app_build\` — Create an app BYOS build (works with sample data)\n`;
98+
output += `4. \`percy_get_projects\` — List all projects in your org\n`;
99+
62100
return { content: [{ type: "text", text: output }] };
63101
}

0 commit comments

Comments
 (0)