@browserstack/mcp-server 1.2.24 → 1.2.25-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.
@@ -25,6 +25,7 @@ export interface TestCaseCreateRequest {
25
25
  automation_status?: string;
26
26
  priority?: string;
27
27
  template?: string;
28
+ template_id?: number;
28
29
  }
29
30
  export interface TestCaseResponse {
30
31
  data: {
@@ -40,6 +41,7 @@ export interface TestCaseResponse {
40
41
  }>;
41
42
  tags: string[];
42
43
  template: string;
44
+ template_id?: number;
43
45
  description: string;
44
46
  preconditions: string;
45
47
  title: string;
@@ -76,6 +78,7 @@ export declare const CreateTestCaseSchema: z.ZodObject<{
76
78
  automation_status: z.ZodOptional<z.ZodString>;
77
79
  priority: z.ZodOptional<z.ZodString>;
78
80
  template: z.ZodOptional<z.ZodString>;
81
+ template_id: z.ZodOptional<z.ZodNumber>;
79
82
  }, z.core.$strip>;
80
83
  export declare function sanitizeArgs(args: any): any;
81
84
  export declare function createTestCase(params: TestCaseCreateRequest, config: BrowserStackConfig): Promise<CallToolResult>;
@@ -69,7 +69,11 @@ export const CreateTestCaseSchema = z.object({
69
69
  template: z
70
70
  .string()
71
71
  .optional()
72
- .describe("Template internal slug, e.g. 'test_case_steps' or 'test_case_bdd'. Use the slug, not the display name."),
72
+ .describe("System template slug only: 'test_case_steps' or 'test_case_bdd'. For a custom template, use template_id instead."),
73
+ template_id: z
74
+ .number()
75
+ .optional()
76
+ .describe("Numeric ID of a custom template (from listTestCaseTemplates); applies that template. Overrides 'template'."),
73
77
  });
74
78
  export function sanitizeArgs(args) {
75
79
  const cleaned = { ...args };
@@ -83,6 +87,8 @@ export function sanitizeArgs(args) {
83
87
  delete cleaned.automation_status;
84
88
  if (cleaned.template === null)
85
89
  delete cleaned.template;
90
+ if (cleaned.template_id === null)
91
+ delete cleaned.template_id;
86
92
  if (cleaned.issue_tracker) {
87
93
  if (cleaned.issue_tracker.name === undefined ||
88
94
  cleaned.issue_tracker.host === undefined) {
@@ -107,24 +113,121 @@ async function normalizePriority(projectIdentifier, priority, config) {
107
113
  return priority;
108
114
  }
109
115
  }
116
+ /**
117
+ * Read a freshly-created test case back to learn which template was actually
118
+ * applied. The create response does not echo template_id, but the v1 search
119
+ * endpoint does. Returns undefined on any failure (caller then skips the
120
+ * verification warning rather than blocking the success path).
121
+ */
122
+ async function fetchAppliedTemplateId(numericProjectId, identifier, config) {
123
+ try {
124
+ const tmBaseUrl = await getTMBaseURL(config);
125
+ const resp = await apiClient.get({
126
+ url: `${tmBaseUrl}/api/v1/projects/${encodeURIComponent(numericProjectId)}/test-cases/search?q%5Bquery%5D=${encodeURIComponent(identifier)}`,
127
+ headers: {
128
+ "API-TOKEN": getBrowserStackAuth(config),
129
+ accept: "application/json, text/plain, */*",
130
+ },
131
+ });
132
+ const cases = resp.data?.test_cases ?? [];
133
+ const match = cases.find((c) => c.identifier === identifier);
134
+ return match?.template_id;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ /**
141
+ * The v1 create endpoint (used when a template_id is requested) keys
142
+ * custom_fields by numeric field id with option *ids* — unlike the v2 endpoint,
143
+ * which keys by field name with option *values*. Translate the MCP's by-name
144
+ * shape into v1's by-id shape using the project's form fields. Best-effort:
145
+ * unknown fields/options pass through unchanged.
146
+ */
147
+ async function toV1CustomFields(customFields, numericProjectId, config) {
148
+ let defs = [];
149
+ try {
150
+ const formFields = await fetchFormFields(numericProjectId, config);
151
+ defs = Array.isArray(formFields?.custom_fields)
152
+ ? formFields.custom_fields
153
+ : [];
154
+ }
155
+ catch {
156
+ return customFields;
157
+ }
158
+ const byName = new Map(defs.map((f) => [f.field_name, f]));
159
+ const out = {};
160
+ for (const [name, value] of Object.entries(customFields)) {
161
+ const def = byName.get(name);
162
+ if (!def) {
163
+ out[name] = value; // unknown field name — leave as-is
164
+ continue;
165
+ }
166
+ const isOptionField = def.field_type === "field_dropdown" ||
167
+ def.field_type === "field_multi_dropdown";
168
+ if (isOptionField) {
169
+ const optionIdByValue = new Map();
170
+ for (const o of (def.option_values ?? [])) {
171
+ optionIdByValue.set(String(o.option_value), o.id);
172
+ }
173
+ const toOptionId = (v) => optionIdByValue.get(String(v)) ?? v;
174
+ out[String(def.id)] = Array.isArray(value)
175
+ ? value.map(toOptionId)
176
+ : toOptionId(value);
177
+ }
178
+ else {
179
+ out[String(def.id)] = value;
180
+ }
181
+ }
182
+ return out;
183
+ }
110
184
  export async function createTestCase(params, config) {
111
185
  const testCaseParams = { ...params };
112
186
  if (testCaseParams.priority !== undefined) {
113
187
  testCaseParams.priority = await normalizePriority(params.project_identifier, testCaseParams.priority, config);
114
188
  }
115
- const body = { test_case: testCaseParams };
116
189
  const authString = getBrowserStackAuth(config);
117
190
  const [username, password] = authString.split(":");
118
191
  try {
119
192
  const tmBaseUrl = await getTMBaseURL(config);
120
- const response = await apiClient.post({
121
- url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(params.project_identifier)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
122
- headers: {
123
- "Content-Type": "application/json",
124
- Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
125
- },
126
- body,
127
- });
193
+ // The public v2 create endpoint silently drops template_id, so a specific
194
+ // (custom) template cannot be applied through it. The v1 create endpoint
195
+ // DOES honour template_id — but it needs the numeric project id, the folder
196
+ // in the body, API-TOKEN auth, and custom_fields keyed by id. Use v1 only
197
+ // when a template_id is requested; otherwise keep the proven v2 path so
198
+ // existing behaviour (incl. custom_fields by name) is unchanged.
199
+ let request;
200
+ if (testCaseParams.template_id !== undefined) {
201
+ const numericProjectId = await projectIdentifierToId(params.project_identifier, config);
202
+ const v1TestCase = { ...testCaseParams };
203
+ delete v1TestCase.project_identifier;
204
+ delete v1TestCase.folder_id;
205
+ delete v1TestCase.custom_fields;
206
+ v1TestCase.test_case_folder_id = Number(params.folder_id);
207
+ if (testCaseParams.custom_fields) {
208
+ v1TestCase.custom_fields = await toV1CustomFields(testCaseParams.custom_fields, numericProjectId, config);
209
+ }
210
+ request = {
211
+ url: `${tmBaseUrl}/api/v1/projects/${encodeURIComponent(numericProjectId)}/test-cases`,
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ "API-TOKEN": authString,
215
+ },
216
+ body: { folder_id: Number(params.folder_id), test_case: v1TestCase },
217
+ };
218
+ }
219
+ else {
220
+ request = {
221
+ url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(params.project_identifier)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
222
+ headers: {
223
+ "Content-Type": "application/json",
224
+ Authorization: "Basic " +
225
+ Buffer.from(`${username}:${password}`).toString("base64"),
226
+ },
227
+ body: { test_case: testCaseParams },
228
+ };
229
+ }
230
+ const response = await apiClient.post(request);
128
231
  const { data } = response.data;
129
232
  if (!data.success) {
130
233
  return {
@@ -140,15 +243,33 @@ export async function createTestCase(params, config) {
140
243
  const tc = data.test_case;
141
244
  const projectId = await projectIdentifierToId(params.project_identifier, config);
142
245
  const content = [];
246
+ // A specific custom template is selected by numeric template_id. The create
247
+ // response does not echo template_id, so read the case back to learn which
248
+ // template was actually applied and warn on mismatch — the public create
249
+ // endpoint may silently ignore the requested template.
250
+ if (params.template_id !== undefined) {
251
+ const appliedId = tc.template_id !== undefined
252
+ ? Number(tc.template_id)
253
+ : await fetchAppliedTemplateId(projectId, tc.identifier, config);
254
+ if (appliedId !== undefined && appliedId !== Number(params.template_id)) {
255
+ content.push({
256
+ type: "text",
257
+ text: `Warning: requested template_id ${params.template_id} was not applied — the test case uses template_id ${appliedId}. Confirm the id via listTestCaseTemplates and that the template is linked to this project.`,
258
+ });
259
+ }
260
+ }
143
261
  // The TM API silently ignores an unrecognized template slug and falls back
144
262
  // to the default. Surface that instead of letting it pass as success.
145
- if (params.template &&
263
+ // Note: the `template` slug only ever selects a SYSTEM template; a custom
264
+ // template must be selected with template_id.
265
+ if (params.template_id === undefined &&
266
+ params.template &&
146
267
  tc.template &&
147
268
  String(tc.template).toLowerCase() !==
148
269
  String(params.template).toLowerCase()) {
149
270
  content.push({
150
271
  type: "text",
151
- text: `Warning: requested template "${params.template}" was not applied — the test case was created with "${tc.template}". BrowserStack expects the template's internal slug (e.g. "test_case_steps", "test_case_bdd") and silently uses the default for unrecognized values.`,
272
+ text: `Warning: requested template "${params.template}" was not applied — the test case was created with "${tc.template}". The 'template' field accepts only the system slugs "test_case_steps" or "test_case_bdd"; for a custom template pass template_id (see listTestCaseTemplates).`,
152
273
  });
153
274
  }
154
275
  content.push({
@@ -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 listing test-case templates in BrowserStack Test Management.
6
+ */
7
+ export declare const ListTemplatesSchema: z.ZodObject<{
8
+ name: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export type ListTemplatesArgs = z.infer<typeof ListTemplatesSchema>;
11
+ /**
12
+ * Lists test-case templates (group-wide) so callers can resolve a template
13
+ * name to the numeric template_id.
14
+ *
15
+ * Custom templates share a step_type (test_case_steps | test_case_bdd) with the
16
+ * system templates, so the slug cannot identify them — only the id can. The
17
+ * list is account-wide; a template must also be linked to the target project to
18
+ * be usable there.
19
+ */
20
+ export declare function listTemplates(args: ListTemplatesArgs, config: BrowserStackConfig): Promise<CallToolResult>;
@@ -0,0 +1,78 @@
1
+ import { apiClient } from "../../lib/apiClient.js";
2
+ import { z } from "zod";
3
+ import { formatAxiosError } from "../../lib/error.js";
4
+ import { getBrowserStackAuth } from "../../lib/get-auth.js";
5
+ import { getTMBaseURL } from "../../lib/tm-base-url.js";
6
+ /**
7
+ * Schema for listing test-case templates in BrowserStack Test Management.
8
+ */
9
+ export const ListTemplatesSchema = z.object({
10
+ name: z
11
+ .string()
12
+ .optional()
13
+ .describe("Case-insensitive substring filter on template name."),
14
+ });
15
+ /**
16
+ * Lists test-case templates (group-wide) so callers can resolve a template
17
+ * name to the numeric template_id.
18
+ *
19
+ * Custom templates share a step_type (test_case_steps | test_case_bdd) with the
20
+ * system templates, so the slug cannot identify them — only the id can. The
21
+ * list is account-wide; a template must also be linked to the target project to
22
+ * be usable there.
23
+ */
24
+ export async function listTemplates(args, config) {
25
+ try {
26
+ const tmBaseUrl = await getTMBaseURL(config);
27
+ // Verified working with API-TOKEN auth (same surface as form-fields-v2).
28
+ const resp = await apiClient.get({
29
+ url: `${tmBaseUrl}/api/v1/admin-v2/settings/templates?entity_type=TestCase&paginated=false`,
30
+ headers: {
31
+ "API-TOKEN": getBrowserStackAuth(config),
32
+ accept: "application/json, text/plain, */*",
33
+ },
34
+ });
35
+ let templates = resp.data?.templates ?? [];
36
+ if (args.name) {
37
+ const needle = args.name.toLowerCase();
38
+ templates = templates.filter((t) => (t.name ?? "").toLowerCase().includes(needle));
39
+ }
40
+ if (templates.length === 0) {
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: args.name
46
+ ? `No templates matching "${args.name}".`
47
+ : "No templates found.",
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ const summary = templates
53
+ .map((t) => `• [template_id=${t.id}] ${t.name} — step_type=${t.step_type}${t.is_system ? " (system)" : ""}${t.is_default ? " (default)" : ""}${t.enabled === false ? " (disabled)" : ""}`)
54
+ .join("\n");
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: `Found ${templates.length} template(s):\n\n${summary}`,
60
+ },
61
+ {
62
+ type: "text",
63
+ text: JSON.stringify(templates.map((t) => ({
64
+ template_id: t.id,
65
+ name: t.name,
66
+ step_type: t.step_type,
67
+ is_system: t.is_system,
68
+ is_default: t.is_default,
69
+ enabled: t.enabled,
70
+ })), null, 2),
71
+ },
72
+ ],
73
+ };
74
+ }
75
+ catch (err) {
76
+ return formatAxiosError(err, "Failed to list templates");
77
+ }
78
+ }
@@ -6,6 +6,7 @@ import { TestCaseCreateRequest } from "./testmanagement-utils/create-testcase.js
6
6
  import { TestCaseUpdateRequest } from "./testmanagement-utils/update-testcase.js";
7
7
  import { ListTestCasesSchema } from "./testmanagement-utils/list-testcases.js";
8
8
  import { ListFoldersSchema } from "./testmanagement-utils/list-folders.js";
9
+ import { ListTemplatesSchema } from "./testmanagement-utils/list-templates.js";
9
10
  import { CreateTestRunSchema } from "./testmanagement-utils/create-testrun.js";
10
11
  import { ListTestRunsSchema } from "./testmanagement-utils/list-testruns.js";
11
12
  import { UpdateTestRunSchema } from "./testmanagement-utils/update-testrun.js";
@@ -38,6 +39,10 @@ export declare function listTestCasesTool(args: z.infer<typeof ListTestCasesSche
38
39
  * Lists folders in a project (or sub-folders under a parent folder).
39
40
  */
40
41
  export declare function listFoldersTool(args: z.infer<typeof ListFoldersSchema>, config: BrowserStackConfig, server: McpServer): Promise<CallToolResult>;
42
+ /**
43
+ * Lists test-case templates so callers can resolve a name to a template_id.
44
+ */
45
+ export declare function listTemplatesTool(args: z.infer<typeof ListTemplatesSchema>, config: BrowserStackConfig, server: McpServer): Promise<CallToolResult>;
41
46
  /**
42
47
  * Creates a test run in BrowserStack Test Management.
43
48
  */
@@ -5,6 +5,7 @@ import { createTestCase as createTestCaseAPI, sanitizeArgs, CreateTestCaseSchema
5
5
  import { updateTestCase as updateTestCaseAPI, UpdateTestCaseSchema, } from "./testmanagement-utils/update-testcase.js";
6
6
  import { listTestCases, ListTestCasesSchema, } from "./testmanagement-utils/list-testcases.js";
7
7
  import { listFolders, ListFoldersSchema, } from "./testmanagement-utils/list-folders.js";
8
+ import { listTemplates, ListTemplatesSchema, } from "./testmanagement-utils/list-templates.js";
8
9
  import { CreateTestRunSchema, createTestRun, } from "./testmanagement-utils/create-testrun.js";
9
10
  import { ListTestRunsSchema, listTestRuns, } from "./testmanagement-utils/list-testruns.js";
10
11
  import { UpdateTestRunSchema, updateTestRun, } from "./testmanagement-utils/update-testrun.js";
@@ -128,6 +129,27 @@ export async function listFoldersTool(args, config, server) {
128
129
  };
129
130
  }
130
131
  }
132
+ /**
133
+ * Lists test-case templates so callers can resolve a name to a template_id.
134
+ */
135
+ export async function listTemplatesTool(args, config, server) {
136
+ try {
137
+ trackMCP("listTestCaseTemplates", server.server.getClientVersion(), undefined, config);
138
+ return await listTemplates(args, config);
139
+ }
140
+ catch (err) {
141
+ trackMCP("listTestCaseTemplates", server.server.getClientVersion(), err, config);
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text",
146
+ text: `Failed to list templates: ${err instanceof Error ? err.message : "Unknown error"}. Please open an issue on GitHub if the problem persists`,
147
+ },
148
+ ],
149
+ isError: true,
150
+ };
151
+ }
152
+ }
131
153
  /**
132
154
  * Creates a test run in BrowserStack Test Management.
133
155
  */
@@ -375,6 +397,7 @@ export default function addTestManagementTools(server, config) {
375
397
  tools.updateTestCase = server.tool("updateTestCase", "Update an existing test case in BrowserStack Test Management. Any subset of the following fields may be changed: name, description, preconditions, test_case_steps, owner, priority, case_type, automation_status, status, tags, issues, custom_fields. Only the supplied fields are modified.", UpdateTestCaseSchema.shape, (args) => updateTestCaseTool(args, config, server));
376
398
  tools.listTestCases = server.tool("listTestCases", "List test cases in a project, optionally scoped to a specific folder. Omit folder_id to list all test cases in the project; provide folder_id (discoverable via listFolders) to list only that folder's cases. Supports filters: case_type, priority, pagination.", ListTestCasesSchema.shape, (args) => listTestCasesTool(args, config, server));
377
399
  tools.listFolders = server.tool("listFolders", "List folders in a BrowserStack Test Management project, returning each folder's id and name (plus case counts and sub-folder counts). Pass parent_id to list sub-folders under a specific folder instead of top-level folders.", ListFoldersSchema.shape, (args) => listFoldersTool(args, config, server));
400
+ tools.listTestCaseTemplates = server.tool("listTestCaseTemplates", "List test-case templates with their numeric template_id. Use the id with createTestCase to apply a custom template (the 'template' slug only selects system templates).", ListTemplatesSchema.shape, (args) => listTemplatesTool(args, config, server));
378
401
  tools.createTestRun = server.tool("createTestRun", "Create a test run in BrowserStack Test Management.", CreateTestRunSchema.shape, (args) => createTestRunTool(args, config, server));
379
402
  tools.listTestRuns = server.tool("listTestRuns", "List test runs in a project with optional filters (date ranges, assignee, state, etc.)", ListTestRunsSchema.shape, (args) => listTestRunsTool(args, config, server));
380
403
  tools.updateTestRun = server.tool("updateTestRun", "Update a test run in BrowserStack Test Management.", UpdateTestRunSchema.shape, (args) => updateTestRunTool(args, config, server));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserstack/mcp-server",
3
- "version": "1.2.24",
3
+ "version": "1.2.25-beta.1",
4
4
  "description": "BrowserStack's Official MCP Server",
5
5
  "mcpName": "io.github.browserstack/mcp-server",
6
6
  "main": "dist/index.js",