@browserstack/mcp-server 1.2.17 → 1.2.18-beta.1

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,40 +1,448 @@
1
1
  import { z } from "zod";
2
- import { getSelfHealSelectors } from "./selfheal-utils/selfheal.js";
2
+ import { getSelfHealSelectors, fetchSelfHealingReportByBuild, } from "./selfheal-utils/selfheal.js";
3
+ import { fetchTestCodeForSessions, formatTestCodeAsContext, describeTestCodeFetchIssues, } from "./selfheal-utils/fetch-test-code.js";
3
4
  import logger from "../logger.js";
4
5
  import { trackMCP } from "../lib/instrumentation.js";
5
- // Tool function that fetches self-healing selectors
6
+ // Local helper: returns the server-configured BrowserStack credentials, or
7
+ // null when either is missing. Lives here because the self-heal tools need
8
+ // to degrade gracefully — `getBrowserStackAuth` throws, which is wrong for
9
+ // these flows. Credentials are sourced from the server config (env) only;
10
+ // they are deliberately NOT accepted as tool arguments, since pasting them
11
+ // in chat is a credential-leak vector.
12
+ function resolveBrowserStackAuth(config) {
13
+ const username = (config["browserstack-username"] || "").trim();
14
+ const accessKey = (config["browserstack-access-key"] || "").trim();
15
+ if (!username || !accessKey)
16
+ return null;
17
+ return { config };
18
+ }
19
+ const CREDS_PROMPT_TEXT = "BrowserStack credentials are not configured on the MCP server. " +
20
+ "Ask the user to set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY " +
21
+ "in the server environment (https://www.browserstack.com/accounts/profile/details), " +
22
+ "restart the MCP server, and retry. Do NOT ask the user to paste credentials in chat.";
23
+ function credsMissingResult() {
24
+ return {
25
+ content: [{ type: "text", text: CREDS_PROMPT_TEXT }],
26
+ };
27
+ }
28
+ function friendlyApiError(error, context) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ if (/\b401\b|Unauthorized/i.test(message)) {
31
+ return `Authentication with BrowserStack failed while ${context}. The username/access key is incorrect or the user does not have access to this resource. Ask the user to re-check the credentials.`;
32
+ }
33
+ if (/\b404\b|Not Found/i.test(message)) {
34
+ return `BrowserStack returned 404 while ${context}. The identifier is likely invalid or the build/session has no self-healing data. Ask the user to verify the ID.`;
35
+ }
36
+ if (/\b403\b|Forbidden/i.test(message)) {
37
+ return `BrowserStack returned 403 while ${context}. The provided credentials do not have permission to access this resource.`;
38
+ }
39
+ return `Error ${context}: ${message}`;
40
+ }
41
+ function trimOrUndefined(value) {
42
+ if (typeof value !== "string")
43
+ return undefined;
44
+ const trimmed = value.trim();
45
+ return trimmed.length > 0 ? trimmed : undefined;
46
+ }
47
+ /**
48
+ * Wraps per-status guidance in a HEAD-OF-RESPONSE banner the LLM is unlikely
49
+ * to paraphrase away. The banner must come BEFORE the plan JSON so the model
50
+ * anchors on it when composing its user-facing reply.
51
+ */
52
+ function buildWarningBanner(body) {
53
+ return [
54
+ "## ATTENTION — test code fetch did not return usable source",
55
+ "",
56
+ "Read this block BEFORE composing your reply to the user. When relaying " +
57
+ "this to the user, quote the provided phrasings below as closely as " +
58
+ "possible — do NOT compress multiple statuses into a generic " +
59
+ "'credentials or session issue' message. If the status below is " +
60
+ "`non_sdk_build`, it is definitely NOT a credentials problem.",
61
+ "",
62
+ body,
63
+ "",
64
+ "---",
65
+ "",
66
+ ].join("\n");
67
+ }
68
+ /**
69
+ * Emits the same warning banner for the fetch-selectors tool (session and
70
+ * build modes) when any session's test code came back unusable.
71
+ */
72
+ function buildTestCodeFetchBanner(testCodeResults) {
73
+ const problematic = testCodeResults.filter((t) => t.status !== "ok");
74
+ if (problematic.length === 0)
75
+ return "";
76
+ return buildWarningBanner(describeTestCodeFetchIssues(problematic));
77
+ }
78
+ function isPlainObject(value) {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }
81
+ function firstDefined(...values) {
82
+ for (const v of values) {
83
+ if (v !== undefined && v !== null && v !== "")
84
+ return v;
85
+ }
86
+ return undefined;
87
+ }
88
+ /**
89
+ * Normalizes a locator reference to the canonical `{ strategy, value }` shape.
90
+ * Accepts alternate keys used by the BrowserStack healing report and common
91
+ * LLM/user mistakes (`type`, `using`). Passes the value through untouched if
92
+ * it is not an object so zod can still produce a clear error later.
93
+ */
94
+ function normalizeLocatorRef(input) {
95
+ if (!isPlainObject(input))
96
+ return input;
97
+ const strategy = firstDefined(input.strategy, input.type, input.using, input.locatorType, input.by);
98
+ const value = firstDefined(input.value, input.selector, input.locator);
99
+ return { strategy, value };
100
+ }
101
+ /**
102
+ * Normalizes a single locator pair. Accepts:
103
+ * - `{ original, healed, thought }` (canonical)
104
+ * - `{ original_locator, healed_locator, healing_thought }` (report-native)
105
+ * - `{ from, to, reason }` (occasional LLM variant)
106
+ */
107
+ function normalizeLocatorPair(input) {
108
+ if (!isPlainObject(input))
109
+ return input;
110
+ const original = normalizeLocatorRef(firstDefined(input.original, input.original_locator, input.from));
111
+ const healed = normalizeLocatorRef(firstDefined(input.healed, input.healed_locator, input.to));
112
+ const thought = firstDefined(input.thought, input.healing_thought, input.reason, input.note);
113
+ const out = { original, healed };
114
+ if (thought !== undefined)
115
+ out.thought = thought;
116
+ return out;
117
+ }
118
+ /**
119
+ * Normalizes a single session object. Accepts snake_case session ids and
120
+ * the healing-report-native `healed_selectors` array as an alias for
121
+ * `locators`.
122
+ */
123
+ function normalizeSession(input) {
124
+ if (!isPlainObject(input))
125
+ return input;
126
+ const sessionId = firstDefined(input.sessionId, input.session_id, input.id, input.session_uuid, input.sessionUuid);
127
+ const sessionName = firstDefined(input.sessionName, input.session_name, input.name);
128
+ const rawLocators = Array.isArray(input.locators)
129
+ ? input.locators
130
+ : Array.isArray(input.healed_selectors)
131
+ ? input.healed_selectors
132
+ : Array.isArray(input.selectors)
133
+ ? input.selectors
134
+ : [];
135
+ const locators = rawLocators.map(normalizeLocatorPair);
136
+ const out = { locators };
137
+ if (sessionId !== undefined)
138
+ out.sessionId = sessionId;
139
+ if (sessionName !== undefined)
140
+ out.sessionName = sessionName;
141
+ return out;
142
+ }
143
+ /**
144
+ * Normalizes the `sessions` argument. Accepts:
145
+ * - an array of sessions (canonical)
146
+ * - a single session object (wrapped in an array)
147
+ * - an envelope like `{ action, sessions: [...] }` (unwrap)
148
+ * - the raw healing report shape `{ healing_logs: [...] }` (map to sessions)
149
+ */
150
+ function normalizeSessionsInput(input) {
151
+ if (input === undefined || input === null)
152
+ return input;
153
+ if (Array.isArray(input)) {
154
+ return input.map(normalizeSession);
155
+ }
156
+ if (isPlainObject(input)) {
157
+ if (Array.isArray(input.sessions)) {
158
+ return input.sessions.map(normalizeSession);
159
+ }
160
+ if (Array.isArray(input.healing_logs)) {
161
+ return input.healing_logs.map(normalizeSession);
162
+ }
163
+ // Looks like a lone session payload — wrap it.
164
+ if ("locators" in input ||
165
+ "healed_selectors" in input ||
166
+ "sessionId" in input ||
167
+ "session_id" in input) {
168
+ return [normalizeSession(input)];
169
+ }
170
+ }
171
+ // Unknown shape — let zod surface a descriptive error.
172
+ return input;
173
+ }
6
174
  export async function fetchSelfHealSelectorTool(args, config) {
175
+ const sessionId = trimOrUndefined(args.sessionId);
176
+ const buildUuid = trimOrUndefined(args.buildUuid);
177
+ if ((sessionId && buildUuid) || (!sessionId && !buildUuid)) {
178
+ return {
179
+ content: [
180
+ {
181
+ type: "text",
182
+ text: "Please provide exactly one of `sessionId` or `buildUuid`. " +
183
+ "Use `buildUuid` when the user shares a build UUID (the tool will " +
184
+ "fetch the full self-healing report and test code for every " +
185
+ "session in the build). Use `sessionId` when the user wants to " +
186
+ "inspect a single Automate/App-Automate session.",
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ const resolved = resolveBrowserStackAuth(config);
192
+ if (!resolved)
193
+ return credsMissingResult();
194
+ const effectiveConfig = resolved.config;
7
195
  try {
8
- const selectors = await getSelfHealSelectors(args.sessionId, config);
196
+ if (sessionId) {
197
+ const [selectors, testCodeResults] = await Promise.all([
198
+ getSelfHealSelectors(sessionId, effectiveConfig, args.sessionType ?? "automate"),
199
+ fetchTestCodeForSessions([sessionId], effectiveConfig),
200
+ ]);
201
+ const testCodeContext = formatTestCodeAsContext(testCodeResults);
202
+ const banner = buildTestCodeFetchBanner(testCodeResults);
203
+ return {
204
+ content: [
205
+ {
206
+ type: "text",
207
+ text: banner +
208
+ "Self-heal selectors fetched successfully (sessionId log-parse mode):\n" +
209
+ JSON.stringify(selectors, null, 2) +
210
+ (testCodeContext
211
+ ? "\n\nThe following test code was found for this session. " +
212
+ "Use it to understand the test intent and make more accurate " +
213
+ "selector replacements:\n" +
214
+ testCodeContext
215
+ : ""),
216
+ },
217
+ ],
218
+ };
219
+ }
220
+ const report = await fetchSelfHealingReportByBuild(buildUuid, effectiveConfig);
221
+ // Extract unique session IDs from the healing report and fetch test code
222
+ const sessionIds = Array.from(new Set((report.healing_logs ?? [])
223
+ .map((log) => log.session_id)
224
+ .filter(Boolean)));
225
+ const testCodeResults = sessionIds.length > 0
226
+ ? await fetchTestCodeForSessions(sessionIds, effectiveConfig)
227
+ : [];
228
+ const testCodeContext = formatTestCodeAsContext(testCodeResults);
229
+ const banner = buildTestCodeFetchBanner(testCodeResults);
9
230
  return {
10
231
  content: [
11
232
  {
12
233
  type: "text",
13
- text: "Self-heal selectors fetched successfully" +
14
- JSON.stringify(selectors),
234
+ text: banner +
235
+ "Self-healing report fetched successfully (buildUuid mode). " +
236
+ "Work session-by-session: for each entry in `healing_logs[]`, " +
237
+ "pass `healed_selectors[]` to `prepareSelfHealingPlan` so the " +
238
+ "calling LLM can apply the edits with its own file-editing " +
239
+ "tools (this server never writes files).\n" +
240
+ JSON.stringify(report, null, 2) +
241
+ (testCodeContext
242
+ ? "\n\nThe following test code was found for sessions in this build. " +
243
+ "Use it to understand the test intent, locate the exact files " +
244
+ "containing the selectors, and make more accurate replacements:\n" +
245
+ testCodeContext
246
+ : ""),
15
247
  },
16
248
  ],
17
249
  };
18
250
  }
19
251
  catch (error) {
20
252
  logger.error("Error fetching self-heal selector suggestions", error);
21
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
253
+ const context = sessionId
254
+ ? `fetching self-heal selectors for sessionId=${sessionId}`
255
+ : `fetching self-healing report for buildUuid=${buildUuid}`;
256
+ return {
257
+ content: [{ type: "text", text: friendlyApiError(error, context) }],
258
+ isError: true,
259
+ };
260
+ }
261
+ }
262
+ const PLAN_INSTRUCTIONS = [
263
+ "## How to use this plan",
264
+ "",
265
+ "This tool does NOT modify any files. It returns the healed-locator plan",
266
+ "and per-session test source code so that YOU (the calling LLM) can make",
267
+ "surgical edits with your own file-editing tools.",
268
+ "",
269
+ "For each session in the plan:",
270
+ " 1. Read each `tests[].code` to understand the test intent — especially",
271
+ " the step the healed locator belongs to.",
272
+ " 2. Locate the exact call site(s) in the user's local project that",
273
+ " correspond to the original locator. Use `tests[].filename` as the",
274
+ " first place to look.",
275
+ " 3. Edit ONLY the call sites that belong to this session's failing step.",
276
+ " A single id / class value may appear in many places; do NOT blindly",
277
+ " find/replace across the repo (e.g. two different elements both using",
278
+ ' id="foo" must be resolved individually).',
279
+ " 4. The healing report speaks in CSS/xpath, but the source often uses",
280
+ " `By.id(...)`, `By.name(...)`, `By.css(...)` wrappers. Translate as",
281
+ " needed — e.g. `*[id=\"email-field\"]` → `By.id('email-field')`, or",
282
+ ' `input[id="user-email-input"]` → `By.css(\'input[id="user-email-input"]\')`.',
283
+ " 5. Confirm ambiguous file paths with the user before editing.",
284
+ "",
285
+ "If `tests[]` is empty for a session, the BrowserStack API did not return",
286
+ "test code (credentials missing, or the session has no associated test",
287
+ "runs). Ask the user to point you at the right file before editing.",
288
+ ].join("\n");
289
+ export async function prepareSelfHealingPlanTool(args, config) {
290
+ const rawArgs = args;
291
+ // Normalize flexible input shapes so bare handler calls are just as lenient
292
+ // as schema-validated MCP calls. The normalizer also looks inside alternate
293
+ // top-level keys (e.g. the user pasted the whole args object into the
294
+ // `sessions` field by accident).
295
+ const sessionsRaw = firstDefined(args.sessions, rawArgs.healing_logs, rawArgs.sessionsList);
296
+ const normalized = normalizeSessionsInput(sessionsRaw);
297
+ const sessions = (Array.isArray(normalized) ? normalized : []);
298
+ if (sessions.length === 0) {
22
299
  return {
23
300
  content: [
24
301
  {
25
302
  type: "text",
26
- text: `Error fetching self-heal selector suggestions: ${errorMessage}`,
303
+ text: "No sessions provided. Pass `sessions: [{ sessionId, locators: " +
304
+ "[{ original, healed, thought? }] }]` — typically copied from the " +
305
+ "self-healing report returned by `fetchSelfHealedSelectors`.",
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ // Keep only locator pairs where both sides are non-empty and distinct; the
311
+ // rest are surfaced as `skipped` so the caller can see what was ignored.
312
+ const plannedSessions = [];
313
+ const skipped = [];
314
+ for (const session of sessions) {
315
+ const locators = Array.isArray(session?.locators) ? session.locators : [];
316
+ const validLocators = [];
317
+ for (const loc of locators) {
318
+ const originalValue = loc?.original?.value?.trim?.();
319
+ const healedValue = loc?.healed?.value?.trim?.();
320
+ if (!originalValue || !healedValue) {
321
+ skipped.push({
322
+ reason: "missing original.value or healed.value",
323
+ sessionId: session?.sessionId,
324
+ });
325
+ continue;
326
+ }
327
+ if (originalValue === healedValue) {
328
+ skipped.push({
329
+ reason: "original and healed values are identical",
330
+ sessionId: session?.sessionId,
331
+ });
332
+ continue;
333
+ }
334
+ validLocators.push({
335
+ original: {
336
+ strategy: loc.original.strategy,
337
+ value: loc.original.value,
338
+ },
339
+ healed: {
340
+ strategy: loc.healed.strategy,
341
+ value: loc.healed.value,
342
+ },
343
+ ...(loc.thought ? { thought: loc.thought } : {}),
344
+ });
345
+ }
346
+ if (validLocators.length > 0) {
347
+ plannedSessions.push({
348
+ sessionId: session.sessionId,
349
+ sessionName: session.sessionName,
350
+ locators: validLocators,
351
+ tests: [],
352
+ });
353
+ }
354
+ }
355
+ if (plannedSessions.length === 0) {
356
+ return {
357
+ content: [
358
+ {
359
+ type: "text",
360
+ text: "No applicable locator pairs found in the provided sessions. " +
361
+ "Every entry was missing values or had identical original/healed " +
362
+ "locators.\n" +
363
+ JSON.stringify({ skipped }, null, 2),
27
364
  },
28
365
  ],
29
- isError: true,
30
366
  };
31
367
  }
368
+ // Try to enrich the plan with test code per session. Missing credentials
369
+ // are not fatal — the plan is still useful to the caller.
370
+ const resolved = resolveBrowserStackAuth(config);
371
+ const sessionIds = plannedSessions
372
+ .map((s) => trimOrUndefined(s?.sessionId))
373
+ .filter((id) => Boolean(id));
374
+ let warningBanner = "";
375
+ if (resolved && sessionIds.length > 0) {
376
+ try {
377
+ const testCodeResults = await fetchTestCodeForSessions(sessionIds, resolved.config);
378
+ const resultBySessionId = new Map(testCodeResults.map((t) => [t.sessionId, t]));
379
+ for (const session of plannedSessions) {
380
+ if (session.sessionId && resultBySessionId.has(session.sessionId)) {
381
+ session.tests = resultBySessionId.get(session.sessionId)?.tests ?? [];
382
+ }
383
+ }
384
+ const problematic = testCodeResults.filter((t) => t.status !== "ok");
385
+ if (problematic.length > 0) {
386
+ warningBanner = buildWarningBanner(describeTestCodeFetchIssues(problematic));
387
+ }
388
+ }
389
+ catch (error) {
390
+ logger.warn("Failed to fetch test code during plan preparation", error);
391
+ warningBanner = buildWarningBanner(`### Transport error while fetching test code\n\nDiagnosis: ${error instanceof Error ? error.message : String(error)}. This is not an auth issue.\n\nSay this to the user: "I hit a transport error fetching test code from BrowserStack. Want me to retry, or would you rather share the local test file directly so I can apply the healed locators?"`);
392
+ }
393
+ }
394
+ else if (!resolved && sessionIds.length > 0) {
395
+ warningBanner = buildWarningBanner([
396
+ "### BrowserStack credentials not provided",
397
+ "",
398
+ "Diagnosis: BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY are not " +
399
+ "configured on the MCP server, so test code could not be fetched. " +
400
+ "The plan was still generated from the locator pairs you sent.",
401
+ "",
402
+ "Say this to the user: \"I couldn't pull the test source " +
403
+ "automatically because BrowserStack credentials aren't configured " +
404
+ "on the MCP server. Please set BROWSERSTACK_USERNAME and " +
405
+ "BROWSERSTACK_ACCESS_KEY in the server environment and restart " +
406
+ "the MCP server, or point me at the local test file directly so " +
407
+ 'I can apply the healed locators."',
408
+ "",
409
+ "Do NOT ask the user to paste credentials in chat.",
410
+ ].join("\n"));
411
+ }
412
+ const skippedNote = skipped.length > 0
413
+ ? `\n\n## Skipped locator pairs\n\n${JSON.stringify(skipped, null, 2)}`
414
+ : "";
415
+ return {
416
+ content: [
417
+ {
418
+ type: "text",
419
+ text: warningBanner +
420
+ `${PLAN_INSTRUCTIONS}\n\n## Plan\n\n${JSON.stringify(plannedSessions, null, 2)}` +
421
+ skippedNote,
422
+ },
423
+ ],
424
+ };
32
425
  }
33
426
  // Registers the fetchSelfHealSelector tool with the MCP server
34
427
  export default function addSelfHealTools(server, config) {
35
428
  const tools = {};
36
- tools.fetchSelfHealedSelectors = server.tool("fetchSelfHealedSelectors", "Retrieves AI-generated, self-healed selectors for a BrowserStack Automate session to resolve flaky tests caused by dynamic DOM changes.", {
37
- sessionId: z.string().describe("The session ID of the test run"),
429
+ tools.fetchSelfHealedSelectors = server.tool("fetchSelfHealedSelectors", "Fetch BrowserStack self-healed selectors plus the test source code for " +
430
+ "the run. Provide exactly one of `sessionId` (single Automate / " +
431
+ "App-Automate session) or `buildUuid` (full self-healing report for a " +
432
+ "build). Pass the returned locator pairs to `prepareSelfHealingPlan` " +
433
+ "to plan edits.", {
434
+ sessionId: z
435
+ .string()
436
+ .describe("Session ID. Mutually exclusive with buildUuid.")
437
+ .optional(),
438
+ sessionType: z
439
+ .enum(["automate", "app-automate"])
440
+ .describe("Used with sessionId. Defaults to automate.")
441
+ .optional(),
442
+ buildUuid: z
443
+ .string()
444
+ .describe("Build UUID. Fetches the build's self-healing report.")
445
+ .optional(),
38
446
  }, async (args) => {
39
447
  try {
40
448
  trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion(), undefined, config);
@@ -42,12 +450,63 @@ export default function addSelfHealTools(server, config) {
42
450
  }
43
451
  catch (error) {
44
452
  trackMCP("fetchSelfHealedSelectors", server.server.getClientVersion(), error, config);
45
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
453
+ const context = args.sessionId
454
+ ? `fetching self-heal selectors for sessionId=${args.sessionId}`
455
+ : args.buildUuid
456
+ ? `fetching self-healing report for buildUuid=${args.buildUuid}`
457
+ : "fetching self-heal suggestions";
458
+ return {
459
+ content: [{ type: "text", text: friendlyApiError(error, context) }],
460
+ isError: true,
461
+ };
462
+ }
463
+ });
464
+ const locatorRefSchema = z.object({
465
+ strategy: z
466
+ .string()
467
+ .describe("Locator strategy, e.g. 'css selector', 'xpath'."),
468
+ value: z.string().describe("The locator string itself."),
469
+ });
470
+ const sessionLocatorSchema = z.object({
471
+ original: locatorRefSchema,
472
+ healed: locatorRefSchema,
473
+ thought: z.string().optional(),
474
+ });
475
+ const sessionPayloadSchema = z.object({
476
+ sessionId: z.string().describe("BrowserStack session ID."),
477
+ sessionName: z.string().optional(),
478
+ locators: z
479
+ .array(sessionLocatorSchema)
480
+ .min(1)
481
+ .describe("Healed locator pairs for this session."),
482
+ });
483
+ // Flexible entry point for the `sessions` argument. See normalizers above
484
+ // for the accepted alternate shapes.
485
+ const sessionsFieldSchema = z.preprocess(normalizeSessionsInput, z.array(sessionPayloadSchema).min(1));
486
+ tools.prepareSelfHealingPlan = server.tool("prepareSelfHealingPlan", "Build a self-healing edit plan that bundles locator pairs with test " +
487
+ "source code for the calling LLM to apply with its own editing tools. " +
488
+ "This tool does NOT modify files — it returns structured context so " +
489
+ "the LLM can edit only the relevant call sites (avoids blind " +
490
+ "find/replace across shared ids/classes). `sessions` accepts the " +
491
+ "canonical shape `[{sessionId, locators: [{original, healed, " +
492
+ "thought?}]}]`, a single session object, an envelope `{sessions: " +
493
+ "[...]}`, the raw report `{healing_logs: [...]}` (with " +
494
+ "`healed_selectors` aliasing `locators`), and snake_case keys " +
495
+ "(`session_id`, `original_locator`, `healed_locator`, " +
496
+ "`healing_thought`).", {
497
+ sessions: sessionsFieldSchema.describe("Sessions to plan edits for. See tool description for accepted shapes."),
498
+ }, async (args) => {
499
+ try {
500
+ trackMCP("prepareSelfHealingPlan", server.server.getClientVersion(), undefined, config);
501
+ return await prepareSelfHealingPlanTool(args, config);
502
+ }
503
+ catch (error) {
504
+ trackMCP("prepareSelfHealingPlan", server.server.getClientVersion(), error, config);
46
505
  return {
47
506
  content: [
48
507
  {
49
508
  type: "text",
50
- text: `Error during fetching self-heal suggestions: ${errorMessage}`,
509
+ text: friendlyApiError(error, "preparing self-healing plan"),
51
510
  },
52
511
  ],
53
512
  isError: true,
@@ -7,6 +7,15 @@ export declare function fetchFormFields(projectId: string, config: BrowserStackC
7
7
  default_fields: any;
8
8
  custom_fields: any;
9
9
  }>;
10
+ /**
11
+ * Resolve a default-field input (priority/case_type) to the form's display or
12
+ * internal name, matching case-insensitively. Returns undefined if no match.
13
+ */
14
+ export declare function normalizeDefaultFieldValue(fieldValues: Array<{
15
+ internal_name?: string | null;
16
+ name?: string;
17
+ value: any;
18
+ }>, input: string, emit: "name" | "internal_name"): string | undefined;
10
19
  /**
11
20
  * Trigger AI-based test case generation for a document.
12
21
  */
@@ -16,6 +16,20 @@ export async function fetchFormFields(projectId, config) {
16
16
  });
17
17
  return res.data;
18
18
  }
19
+ /**
20
+ * Resolve a default-field input (priority/case_type) to the form's display or
21
+ * internal name, matching case-insensitively. Returns undefined if no match.
22
+ */
23
+ export function normalizeDefaultFieldValue(fieldValues, input, emit) {
24
+ const normalized = input.toLowerCase().trim();
25
+ const match = fieldValues.find((v) => (v.internal_name ?? "").toLowerCase() === normalized ||
26
+ (v.name ?? "").toLowerCase() === normalized);
27
+ if (!match)
28
+ return undefined;
29
+ if (emit === "name")
30
+ return match.name;
31
+ return match.internal_name ?? match.name;
32
+ }
19
33
  /**
20
34
  * Trigger AI-based test case generation for a document.
21
35
  */
@@ -22,6 +22,7 @@ export interface TestCaseCreateRequest {
22
22
  tags?: string[];
23
23
  custom_fields?: Record<string, string>;
24
24
  automation_status?: string;
25
+ priority?: string;
25
26
  }
26
27
  export interface TestCaseResponse {
27
28
  data: {
@@ -70,6 +71,7 @@ export declare const CreateTestCaseSchema: z.ZodObject<{
70
71
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
71
72
  custom_fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
72
73
  automation_status: z.ZodOptional<z.ZodString>;
74
+ priority: z.ZodOptional<z.ZodString>;
73
75
  }, z.core.$strip>;
74
76
  export declare function sanitizeArgs(args: any): any;
75
77
  export declare function createTestCase(params: TestCaseCreateRequest, config: BrowserStackConfig): Promise<CallToolResult>;
@@ -1,8 +1,9 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
2
  import { z } from "zod";
3
3
  import { formatAxiosError } from "../../lib/error.js";
4
- import { projectIdentifierToId } from "./TCG-utils/api.js";
4
+ import { fetchFormFields, normalizeDefaultFieldValue, projectIdentifierToId, } from "./TCG-utils/api.js";
5
5
  import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
+ import logger from "../../logger.js";
6
7
  export const CreateTestCaseSchema = z.object({
7
8
  project_identifier: z
8
9
  .string()
@@ -54,6 +55,10 @@ export const CreateTestCaseSchema = z.object({
54
55
  .string()
55
56
  .optional()
56
57
  .describe("Automation status of the test case. Common values include 'not_automated', 'automated', 'automation_not_required'."),
58
+ priority: z
59
+ .string()
60
+ .optional()
61
+ .describe("Priority of the test case. Accepts either display name (e.g. 'Critical', 'High', 'Medium', 'Low') or internal name (e.g. 'medium'). If omitted, the project default (usually 'Medium') is applied. Valid values are per-project and discoverable via the form-fields endpoint."),
57
62
  });
58
63
  export function sanitizeArgs(args) {
59
64
  const cleaned = { ...args };
@@ -74,8 +79,27 @@ export function sanitizeArgs(args) {
74
79
  return cleaned;
75
80
  }
76
81
  import { getBrowserStackAuth } from "../../lib/get-auth.js";
82
+ /**
83
+ * Normalize priority to the display name the create endpoint accepts (it
84
+ * rejects lowercase). On lookup failure, pass the raw value through.
85
+ */
86
+ async function normalizePriority(projectIdentifier, priority, config) {
87
+ try {
88
+ const numericProjectId = await projectIdentifierToId(projectIdentifier, config);
89
+ const { default_fields } = await fetchFormFields(numericProjectId, config);
90
+ return (normalizeDefaultFieldValue(default_fields?.priority?.values ?? [], priority, "name") ?? priority);
91
+ }
92
+ catch (err) {
93
+ logger.warn("Failed to normalize priority value; passing through as given: %s", err instanceof Error ? err.message : String(err));
94
+ return priority;
95
+ }
96
+ }
77
97
  export async function createTestCase(params, config) {
78
- const body = { test_case: params };
98
+ const testCaseParams = { ...params };
99
+ if (testCaseParams.priority !== undefined) {
100
+ testCaseParams.priority = await normalizePriority(params.project_identifier, testCaseParams.priority, config);
101
+ }
102
+ const body = { test_case: testCaseParams };
79
103
  const authString = getBrowserStackAuth(config);
80
104
  const [username, password] = authString.split(":");
81
105
  try {
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { BrowserStackConfig } from "../../lib/types.js";
4
+ /**
5
+ * Schema for fetching a single sub-test-plan by identifier under a parent test
6
+ * plan, including its linked test runs.
7
+ */
8
+ export declare const GetSubTestPlanSchema: z.ZodObject<{
9
+ project_identifier: z.ZodString;
10
+ parent_test_plan_identifier: z.ZodString;
11
+ sub_test_plan_identifier: z.ZodString;
12
+ }, z.core.$strip>;
13
+ export type GetSubTestPlanArgs = z.infer<typeof GetSubTestPlanSchema>;
14
+ /**
15
+ * Fetches a sub-test-plan by identifier and best-effort-enriches it with its
16
+ * linked test runs. The enrichment call is fail-soft: any failure (thrown
17
+ * AxiosError or non-success response) is swallowed into an empty runs list
18
+ * without flipping `isError` on the primary response.
19
+ */
20
+ export declare function getSubTestPlan(args: GetSubTestPlanArgs, config: BrowserStackConfig): Promise<CallToolResult>;